mirror of
https://github.com/Heretek-AI/ompweb.git
synced 2026-11-07 10:56:00 -10:00
feat: pi-web - Next.js web interface for pi coding agent
This commit is contained in:
commit
95c7a6553f
45 files changed
+17428
No files matched your search
+41
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,455 @@
|
||||
# Pi Agent Web - Development Notes
|
||||
|
||||
## Project Overview
|
||||
|
||||
A Next.js 15 web interface for the pi coding agent. Lets users browse all pi sessions, switch between them, view chat history with branch support, send new messages, fork sessions, and navigate message trees.
|
||||
|
||||
Start dev server: `npm run dev` (runs on port 3030)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser Next.js Server AgentSession (in-process)
|
||||
│ │ │
|
||||
├─ GET /api/sessions ────▶ reads ~/.pi/agent/sessions/ │
|
||||
├─ GET /api/sessions/[id] reads .jsonl file directly │
|
||||
│ │ │
|
||||
├─ send message ─────────▶ POST /api/agent/[id] │
|
||||
│ │ startRpcSession() ─────────▶│ createAgentSession()
|
||||
│ │ session.send(cmd) ─────────▶│ session.prompt()
|
||||
│ │ │
|
||||
├─ SSE connect ──────────▶ GET /api/agent/[id]/events │
|
||||
│ │ session.onEvent() ◀─────────│ session.subscribe()
|
||||
│◀── data: {...} ─────────│ │
|
||||
```
|
||||
|
||||
**Key change**: Pi is no longer spawned as a subprocess. We use `@mariozechner/pi-coding-agent` (v0.60.0) directly via `createAgentSession()` from the npm package. This gives access to `navigateTree`, `fork`, and all AgentSession methods directly.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Session browsing vs. agent interaction
|
||||
- **Browsing**: reads `.jsonl` files directly via `lib/session-reader.ts`, no AgentSession created
|
||||
- **Sending a message**: `startRpcSession()` creates an AgentSession in-process, `session.prompt()` fires the message
|
||||
|
||||
### AgentSession lifecycle (`lib/rpc-manager.ts`)
|
||||
- One `AgentSessionWrapper` per session id, stored in `globalThis.__piSessions`
|
||||
- `globalThis` used so Next.js hot-reload doesn't lose the Map
|
||||
- Auto-removed from registry when `destroy()` is called
|
||||
- Idle timeout: 10 minutes
|
||||
- Concurrent `startRpcSession()` calls share a single start Promise (lock in `globalThis.__piStartLocks`)
|
||||
- `AgentSessionWrapper.send()` dispatches to the appropriate AgentSession method
|
||||
|
||||
### New session creation (`POST /api/agent/new`)
|
||||
- `SessionManager.create(cwd, sessionsDir)` creates new session
|
||||
- `createAgentSession({ cwd, agentDir, sessionManager })` starts it
|
||||
- Returns the real `sessionId` from `inner.sessionId`
|
||||
|
||||
### Existing session loading
|
||||
- `SessionManager.open(sessionFile, sessionsDir)` opens existing `.jsonl`
|
||||
- `createAgentSession(...)` restores context from file
|
||||
|
||||
### Fork
|
||||
- Calls `session.inner.fork(entryId)` directly on AgentSession
|
||||
- Fork returns `{ newSessionId }` synchronously — no polling needed
|
||||
- **After fork, wrapper is immediately destroyed** (`this.destroy()` inside `send("fork")`)
|
||||
- This is critical: `AgentSession.fork()` mutates inner state (sessionId changes in-place)
|
||||
- Keeping the wrapper alive would cause the next fork on the same session to start from the wrong state
|
||||
- Destroying forces the next request to reload a clean AgentSession from the original file
|
||||
- `onSessionForked(newSessionId)` in AppShell triggers sidebar refresh + ChatWindow remount
|
||||
|
||||
### Fork session file structure
|
||||
- Fork creates a completely independent `.jsonl` file
|
||||
- `parentSession` in the header is **metadata only** — it records where the fork came from, for sidebar tree display
|
||||
- Chat content in the forked file is a full copy of the path up to the fork point; both files are independent
|
||||
- Modifying one file's content has no effect on the other
|
||||
|
||||
### Deleting a session with children (cascade reparent)
|
||||
- Session files can be freely rewritten with `writeFileSync` — pi itself does this during migration and `createBranchedSession`
|
||||
- When deleting a session B that has children C (A→B→C):
|
||||
1. Read B's `parentSession` path (A's absolute path)
|
||||
2. Scan sibling `.jsonl` files for any with `parentSession === B's path`
|
||||
3. Rewrite those files' first-line header with `parentSession = A's path`
|
||||
4. Delete B
|
||||
- After this, C's file directly records A as parent — correct on page refresh with no client-side workarounds
|
||||
|
||||
### Navigate Tree (in-session branching)
|
||||
- Calls `session.inner.navigateTree(targetId, {})` directly
|
||||
- Pi switches to the target node within the same session file
|
||||
- After navigate, sending a new prompt creates a branch in the same session
|
||||
|
||||
### Branch/tree navigation (display)
|
||||
- `BranchNavigator` component shows branch points (nodes with >1 child)
|
||||
- Switching leaf calls `/api/sessions/[id]/context?leafId=`
|
||||
- Each message has an `entryId` from `context.entryIds[]` (parallel array)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
app/
|
||||
page.tsx # root → <AppShell> in <Suspense>
|
||||
layout.tsx
|
||||
globals.css # CSS variables (light theme)
|
||||
api/
|
||||
sessions/
|
||||
route.ts # GET - list all sessions
|
||||
new/route.ts # returns 410 (no longer used)
|
||||
[id]/
|
||||
route.ts # GET/PATCH/DELETE session
|
||||
context/route.ts # GET ?leafId= - context for a specific leaf
|
||||
agent/
|
||||
new/route.ts # POST { cwd, message } - create+start new session
|
||||
[id]/
|
||||
route.ts # GET state, POST any command (prompt/fork/navigate_tree/set_model/abort)
|
||||
events/route.ts # GET SSE stream via session.onEvent()
|
||||
models/route.ts # GET - returns id→name map from ~/.pi/agent/models.json
|
||||
|
||||
components/
|
||||
AppShell.tsx # layout: sidebar + chat area; manages session state + URL
|
||||
SessionSidebar.tsx # cwd dropdown, session tree (parent/child via parentSessionId), actions
|
||||
ChatWindow.tsx # message list, streaming, branch nav, input, fork handler
|
||||
MessageView.tsx # renders user/assistant/toolCall+toolResult messages + markdown
|
||||
ChatInput.tsx # textarea, send/stop buttons, model selector dropdown
|
||||
BranchNavigator.tsx # shows branch points, lets user switch active leaf
|
||||
|
||||
lib/
|
||||
types.ts # TypeScript types (SessionInfo has parentSessionId)
|
||||
session-reader.ts # reads .jsonl files; getModelNameMap() for models.json
|
||||
rpc-manager.ts # AgentSessionWrapper + registry + startRpcSession
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pi Session File Format
|
||||
|
||||
Location: `~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl`
|
||||
|
||||
```jsonl
|
||||
{"type":"session","version":3,"id":"<uuid>","timestamp":"...","cwd":"/path","parentSession":"/path/to/parent.jsonl"}
|
||||
{"type":"model_change","id":"<8hex>","parentId":null,"provider":"zenmux","modelId":"claude-sonnet-4-6","timestamp":"..."}
|
||||
{"type":"message","id":"<8hex>","parentId":"<8hex>","message":{"role":"user","content":"...","timestamp":1234567890},...}
|
||||
{"type":"message","id":"<8hex>","parentId":"<8hex>","message":{"role":"assistant","content":[...],"provider":"zenmux","model":"claude-sonnet-4-6","usage":{...},"stopReason":"stop","timestamp":N},...}
|
||||
{"type":"message","id":"<8hex>","parentId":"<8hex>","message":{"role":"toolResult","toolCallId":"...","toolName":"bash","content":[{"type":"text","text":"..."}],"isError":false,"timestamp":N},...}
|
||||
{"type":"session_info","id":"...","parentId":"...","name":"user-defined name"}
|
||||
```
|
||||
|
||||
### ToolCall field normalization
|
||||
Pi stores toolCall blocks as `{type:"toolCall", id, name, arguments}` but our `ToolCallContent` type uses `{toolCallId, toolName, input}`. Normalization happens in:
|
||||
- `session-reader.ts` `normalizeMessage()` — when loading from file
|
||||
- `ChatWindow.tsx` `handleAgentEvent()` — when receiving streaming events
|
||||
|
||||
### parentSession
|
||||
Session header has `parentSession` field (path to parent `.jsonl`) when created via fork. `extractSessionIdFromPath()` extracts the UUID from the filename. Used in `SessionSidebar` to build parent-child tree.
|
||||
|
||||
---
|
||||
|
||||
## CSS Variables (light theme, `globals.css`)
|
||||
|
||||
```css
|
||||
--bg: #ffffff --bg-panel: #f5f5f5 --bg-hover: #eeeeee
|
||||
--bg-selected: #e8e8e8 --border: #e0e0e0
|
||||
--text: #1a1a1a --text-muted: #6b7280 --text-dim: #9ca3af
|
||||
--accent: #2563eb --user-bg: #eff6ff --tool-bg: #f9fafb
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Props Summary
|
||||
|
||||
### AppShell
|
||||
- Manages: `selectedSession`, `newSessionCwd`, `refreshKey`, `sessionKey`
|
||||
- `sessionKey` increments on real session switches → remounts ChatWindow
|
||||
- `handleSessionForked(newSessionId)` → refresh sidebar + sessionKey++
|
||||
|
||||
### SessionSidebar
|
||||
- `buildSessionTree(sessions)` groups sessions by `parentSessionId` into tree
|
||||
- `resolveAncestor()` walks up the `parentSessionId` chain to find the nearest existing ancestor
|
||||
- Handles missing intermediate nodes (e.g. deleted sessions) by skipping to the next live ancestor
|
||||
- `SessionTreeItem` renders recursive tree with indent lines + fork icon
|
||||
- Sessions with children show collapse/expand chevron
|
||||
- Item height fixed at 54px (rename input + delete confirm stay same height)
|
||||
- On delete: server rewrites children's `parentSession` field before deleting, then client calls `loadSessions()`
|
||||
|
||||
### ChatWindow
|
||||
- `session: SessionInfo | null` + `newSessionCwd: string | null`
|
||||
- `entryIds: string[]` parallel to `messages[]` for fork/navigate buttons
|
||||
- `handleFork(entryId)` — POST fork, reads `newSessionId` from response directly (no polling)
|
||||
- `handleNavigate(entryId)` — calls `navigate_tree` + `loadContext` to switch active leaf in same session
|
||||
- `handleLeafChange(leafId)` — BranchNavigator callback: `navigate_tree` + `loadContext`
|
||||
- On mount: checks if agent isStreaming → reconnects SSE automatically
|
||||
- `message_end` event: appends completed message to `messages[]`, clears streaming state
|
||||
- `agent_end` event: reloads session from file (gets toolResult pairing)
|
||||
|
||||
### MessageView
|
||||
- `toolResults: Map<string, ToolResultMessage>` — paired by toolCallId, rendered inline under toolCall
|
||||
- `entryId?: string` + `onFork?` — Fork button shown on hover of user messages
|
||||
- `prevAssistantEntryId?: string` + `onNavigate?` — "Continue" button on hover of user messages
|
||||
- Calls `navigate_tree(prevAssistantEntryId)` to set leaf to the preceding assistant message
|
||||
- User then types a new message → branches from that point within the same session file
|
||||
- ToolCall: green border/background, play icon, expandable args
|
||||
- ToolResult: indented 18px, return arrow icon, expandable output
|
||||
- User message timestamp shown below bubble (today: HH:MM, older: Mon DD HH:MM)
|
||||
|
||||
### ChatInput
|
||||
- Model selector dropdown (bottom-left, opens upward)
|
||||
- `onModelChange(provider, modelId)` → POST `set_model` to agent
|
||||
- Queue input bar (visible during streaming): Steer / Follow-up toggle + textarea + Send
|
||||
- **Steer** tab (yellow): calls `inner.steer()` — interrupts agent mid-run
|
||||
- **Follow-up** tab (indigo): calls `inner.followUp()` — queues for after agent finishes
|
||||
- **Tools** button (bottom-right): opens `ToolPanel` to toggle active tools
|
||||
- **Compact** button doubles as abort: shows "Compacting…" + red stop icon while `isCompacting`, clicking it calls `abort_compaction`
|
||||
|
||||
### ToolPanel (`components/ToolPanel.tsx`)
|
||||
- Opens above the input bar (absolute positioned relative to the wrapper div)
|
||||
- Loads tools via `get_tools` command on open (fresh each time)
|
||||
- Toggle switches: optimistic UI update + `set_tools` with full active name list
|
||||
- Reverts on API error
|
||||
- Closes on outside click
|
||||
- Footer: "N of M active · takes effect on next turn"
|
||||
|
||||
---
|
||||
|
||||
## AgentSessionWrapper (`lib/rpc-manager.ts`)
|
||||
|
||||
```typescript
|
||||
class AgentSessionWrapper {
|
||||
inner: AgentSession // the actual AgentSession from @mariozechner/pi-coding-agent
|
||||
sessionId: string // delegates to inner.sessionId
|
||||
sessionFile: string // delegates to inner.sessionFile
|
||||
isAlive(): boolean
|
||||
start(): void // subscribes to inner events, starts idle timer
|
||||
onEvent(listener): () => void // returns unsubscribe fn
|
||||
onDestroy(cb): void
|
||||
send(command): Promise<unknown> // dispatches to inner methods
|
||||
destroy(): void
|
||||
}
|
||||
```
|
||||
|
||||
`send()` supported commands: `prompt`, `abort`, `get_state`, `set_model`, `fork`, `navigate_tree`, `steer`, `follow_up`, `compact`, `abort_compaction`, `set_auto_compaction`, `set_auto_retry`, `set_thinking_level`, `get_tools`, `set_tools`
|
||||
|
||||
---
|
||||
|
||||
## Known Behaviours / Notes
|
||||
|
||||
- `@mariozechner/pi-coding-agent` v0.60.0 must be installed in pi-web (`npm install @mariozechner/pi-coding-agent`)
|
||||
- Models loaded from `~/.pi/agent/models.json` via `getModelNameMap()` (cached in memory)
|
||||
- Session sidebar shows top 5 cwds by most-recent modified; auto-selects on load
|
||||
- Fork produces a new session file with `parentSession` pointing to the original; wrapper is destroyed immediately after
|
||||
- `navigate_tree` stays in same session file, creates a branch on next prompt
|
||||
- New fork sessions don't appear in sidebar until the first message is sent (pi defers file write until first assistant response)
|
||||
- SSE reconnect: on ChatWindow mount, checks `GET /api/agent/[id]` for `isStreaming: true` → reconnects SSE
|
||||
- URL `?session=<id>` persists selected session across refresh
|
||||
- Sessions without a valid header (orphaned fork artifacts) are shown as `orphaned: true`, displayed with "incomplete" badge and not clickable
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
### AgentSession.fork() 是为 TUI 设计的,不适合直接在 web 中长期持有
|
||||
|
||||
`fork()` 会**原地修改** AgentSession 的内部状态(sessionId 在调用后立即变成新 session 的 id)。TUI 是单进程单用户,fork 完直接继续用同一个对象没有问题。Web 场景下 AgentSession 以 sessionId 为 key 存在 registry 中,fork 后:
|
||||
|
||||
- registry key 还是旧 id,但 wrapper 内部已经是新 session 状态
|
||||
- 下次用旧 id 请求时拿到的是"已经 fork 出去的" wrapper
|
||||
- 再 fork 就从错误的状态出发,产生意外的中间 session,parentSession 链错乱
|
||||
|
||||
**正确做法**:fork 完立即 `destroy()` wrapper,强制下次请求从原始文件重新加载干净的 AgentSession。
|
||||
|
||||
### 不要在 web 端用轮询检测 AgentSession 内部状态变化
|
||||
|
||||
最初 fork 用轮询 `get_state` 20 次来检测 sessionId 变化,这本质上是在补偿"fork 会改变内部状态"这个设计问题。正确做法是让 fork 同步返回 newSessionId,客户端直接用。
|
||||
|
||||
### Session 文件不是 append-only,可以安全地整体重写
|
||||
|
||||
pi 自己在版本迁移和 `createBranchedSession` 时都会用 `writeFileSync` 覆写整个文件。修改 header 字段(如 `parentSession`)直接重写第一行即可,不会破坏任何约束。
|
||||
|
||||
### parentSession 只是显示用的元数据,与聊天内容完全解耦
|
||||
|
||||
fork 出来的 session 文件内容是独立的完整副本,`parentSession` 仅用于 sidebar 构建树状结构。修改 `parentSession`(比如删除中间节点时的 cascade reparent)对聊天内容零影响。
|
||||
|
||||
### BranchNavigator 的两种分支场景要区分清楚
|
||||
|
||||
- **跨 session 的 fork 树**:sidebar 里按 `parentSessionId` 展示,每个节点是独立文件
|
||||
- **session 内部分支**:同一个 `.jsonl` 文件里,多条 entry 有相同的 `parentId`,`BranchNavigator` 展示,用 `navigate_tree` 切换
|
||||
|
||||
"Continue from here" 按钮触发的是 in-session 分支(`navigate_tree`),Fork 按钮触发的是跨 session 分支(新文件)。两者用途不同,不要混淆。
|
||||
|
||||
---
|
||||
|
||||
## Compact Feature: Current Status and Known Bugs
|
||||
|
||||
### What works
|
||||
- Compact button appears in ChatInput for active sessions
|
||||
- `POST /api/agent/[id]` with `{ type: "compact" }` calls `AgentSession.compact()`
|
||||
- On success, `loadSession()` is called to reload the chat with the compaction summary injected at the top
|
||||
- `isCompacting` state disables the button while compaction is in progress
|
||||
- `compactError` state shows an inline error message on failure
|
||||
- `session-reader.ts` renders compaction correctly: injects a virtual "user" message with the summary, then skips entries before `firstKeptEntryId`, then continues from `compactionIdx + 1`
|
||||
|
||||
### Bug 1: `isCompacting` state is never set back to `false` on success
|
||||
|
||||
**Location**: `ChatWindow.tsx` `handleCompact()` (lines 372–394)
|
||||
|
||||
```typescript
|
||||
const handleCompact = useCallback(async () => {
|
||||
...
|
||||
setIsCompacting(true);
|
||||
try {
|
||||
const res = await fetch(...);
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) {
|
||||
setCompactError(data.error ?? `HTTP ${res.status}`);
|
||||
return; // <-- returns without setIsCompacting(false) — BUG: button stays disabled forever
|
||||
}
|
||||
await loadSession(sid, true);
|
||||
} catch (e) {
|
||||
setCompactError(String(e));
|
||||
} finally {
|
||||
setIsCompacting(false);
|
||||
}
|
||||
}, [isCompacting, loadSession]);
|
||||
```
|
||||
|
||||
The early `return` on error path bypasses `finally` — except it doesn't, because `finally` always runs. **Wait: this is actually fine** — `finally` executes even after `return` in a try/catch/finally. So `setIsCompacting(false)` always runs.
|
||||
|
||||
However, there is a subtler issue: **`compactError` is never cleared on a subsequent successful compact call** unless the user explicitly retries. But `setCompactError(null)` is called at the top of `handleCompact`, so that is also fine.
|
||||
|
||||
**Actual root issue with `isCompacting`**: `isCompacting` in `handleCompact`'s dependency array means the callback is recreated when `isCompacting` changes, but this is a stale closure risk: if `handleCompact` is called while a stale version of the callback is referenced (e.g., via `onCompact` prop not re-rendered), the guard `if (!sid || isCompacting) return` could use a stale `false` value. In practice the prop passes through React re-render so this is low risk.
|
||||
|
||||
### Bug 2: Redundant pre-check in `rpc-manager.ts` that duplicates `AgentSession.compact()` logic
|
||||
|
||||
**Location**: `rpc-manager.ts` `send("compact")` (lines 129–144)
|
||||
|
||||
```typescript
|
||||
case "compact": {
|
||||
const pathEntries = this.inner.sessionManager.getBranch();
|
||||
const lastEntry = pathEntries[pathEntries.length - 1];
|
||||
if (lastEntry?.type === "compaction") {
|
||||
throw new Error("Already compacted");
|
||||
}
|
||||
const messageCount = pathEntries.filter((e) => e.type === "message").length;
|
||||
if (messageCount < 4) {
|
||||
throw new Error("Not enough messages to compact");
|
||||
}
|
||||
const result = await this.inner.compact(...);
|
||||
if (!result?.summary) {
|
||||
throw new Error("Session is too small to compact — add more messages first");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
Problems:
|
||||
1. **`this.inner.sessionManager` is a private field in `AgentSession`** — accessing it via `this.inner.sessionManager` works because `inner` is typed as `any`, but it bypasses encapsulation. If the pi library changes the field name, this silently breaks.
|
||||
2. **The `messageCount < 4` threshold is arbitrary and not aligned with `AgentSession.compact()`'s own `prepareCompaction()` logic**, which uses token-count-based cut points, not message counts. A session with 3 very long messages might be compactable; a session with 4 tiny messages might not be. The pre-check gives misleading errors.
|
||||
3. **Double-checking `!result?.summary`**: `AgentSession.compact()` always returns a `CompactionResult` with a `summary`, or throws. Checking `result?.summary` for falsiness is unnecessary and the error message differs from what the library throws.
|
||||
4. **The pre-check "Already compacted"** is correct in intent but uses `lastEntry.type === "compaction"` — this only catches the case where the very last entry is a compaction. A compaction entry mid-branch (followed by new messages) would pass this check. However, `AgentSession.compact()` handles this correctly internally.
|
||||
|
||||
### Bug 3: `AgentSession.compact()` calls `this.abort()` first — destroys active streaming state
|
||||
|
||||
**Location**: `agent-session.js` `compact()` implementation
|
||||
|
||||
```javascript
|
||||
async compact(customInstructions) {
|
||||
this._disconnectFromAgent();
|
||||
await this.abort(); // <-- aborts current prompt if streaming
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
In the web context, if the user clicks "Compact" while the agent is streaming (which is prevented by the `disabled={isStreaming}` check), the abort would cancel the current run. The UI correctly disables the compact button during streaming (`disabled={isStreaming || isCompacting}`), so this is not currently exposed. But if `agentRunning` state gets out of sync with actual agent state, a compact call could silently abort an ongoing turn.
|
||||
|
||||
### Bug 4: No SSE events emitted for manual compact — UI doesn't update `isCompacting` during long compactions
|
||||
|
||||
`AgentSession.compact()` does NOT emit `auto_compaction_start` / `auto_compaction_end` events for **manual** compaction. Those events are only emitted in `_runAutoCompaction()`. The `isCompacting` getter returns `true` while the `_compactionAbortController` is set, but:
|
||||
|
||||
- The web UI sets `isCompacting = true` client-side before the fetch
|
||||
- The fetch is blocking (awaited) — the button stays disabled until the POST returns
|
||||
- No SSE event pushes `isCompacting` state updates during the LLM summarization call
|
||||
|
||||
**Consequence**: If the LLM call inside `compact()` takes 30+ seconds, the UI shows "Compacting…" for the entire duration. This is tolerable but there is no progress indication. More critically: if the user refreshes the page mid-compact, the `isCompacting` state is lost. On remount, `GET /api/agent/[id]` returns `isCompacting: true` from `inner.isCompacting`, and `setIsCompacting(true)` is called — but there is no SSE event to set it back to `false` when compaction finishes, because:
|
||||
|
||||
```typescript
|
||||
// ChatWindow.tsx mount effect:
|
||||
if (d.state.isCompacting !== undefined) setIsCompacting(d.state.isCompacting);
|
||||
// handleAgentEvent does NOT handle auto_compaction_end or any compact-done signal
|
||||
```
|
||||
|
||||
After refresh mid-compact, the compact button would remain disabled even after compaction completes, until the user switches to another session and back (which remounts ChatWindow).
|
||||
|
||||
**Fix needed**: Either (a) handle `auto_compaction_start`/`auto_compaction_end` SSE events in `handleAgentEvent` to sync `isCompacting`, or (b) after compact POST returns, always reset `isCompacting` and reload (already done), or (c) poll `get_state` after returning from a compact while `isCompacting` is true.
|
||||
|
||||
For auto-compaction specifically, the events ARE emitted over SSE (`auto_compaction_start`, `auto_compaction_end`) but `handleAgentEvent` has no cases for them — so `isCompacting` is never set to `true` during auto-compaction triggered server-side, and the UI shows no indication.
|
||||
|
||||
### Bug 5: `compactError` persists across session switches
|
||||
|
||||
`compactError` state lives in `ChatWindow`. When the user switches sessions, `ChatWindow` is remounted (due to `sessionKey` increment in `AppShell`), so the error is cleared. This is correct. **No bug here.**
|
||||
|
||||
### Bug 6: `loadContext` in `handleCompact` is missing — `entryIds` goes stale
|
||||
|
||||
After compaction, `handleCompact` calls `loadSession(sid, true)`. `loadSession` updates `messages` and `entryIds`. However, `loadContext` is NOT called, which is correct since `loadSession` already replaces the full session data including `entryIds`. **No bug here either**, assuming `loadSession` sets `entryIds` from `d.context.entryIds ?? []`.
|
||||
|
||||
### Bug 7: `compaction` rendering in `session-reader.ts` — entries before `firstKeptEntryId` are included if it's not found
|
||||
|
||||
**Location**: `session-reader.ts` `buildSessionContext()` (lines 287–296)
|
||||
|
||||
```typescript
|
||||
const compactionIdx = path.findIndex(e => e.type === "compaction" && e.id === compaction!.id);
|
||||
let foundFirstKept = false;
|
||||
for (let i = 0; i < compactionIdx; i++) {
|
||||
const entry = path[i];
|
||||
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
|
||||
if (foundFirstKept) appendMessage(entry);
|
||||
}
|
||||
for (let i = compactionIdx + 1; i < path.length; i++) {
|
||||
appendMessage(path[i]);
|
||||
}
|
||||
```
|
||||
|
||||
If `firstKeptEntryId` is not found in the path before the compaction entry (malformed session data), `foundFirstKept` stays `false` and **zero messages** from before the compaction are rendered — the summary is injected but no "kept" pre-compaction messages appear. This is a silent failure with no error.
|
||||
|
||||
More practically: `firstKeptEntryId` should always be present in the path by construction (pi sets it when creating the compaction entry). But if the `.jsonl` file is manually edited or a migration bug occurs, this could silently hide messages.
|
||||
|
||||
### Bug 8: `sid` dependency missing in `handleCompact`
|
||||
|
||||
```typescript
|
||||
const handleCompact = useCallback(async () => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid || isCompacting) return;
|
||||
...
|
||||
}, [isCompacting, loadSession]); // <-- sid is read from ref, not state, so not in deps
|
||||
```
|
||||
|
||||
`sessionIdRef.current` is a mutable ref, not tracked by React deps. This is intentional (same pattern used in `handleSend`, `handleFork`, etc.) and correct — refs don't need to be in deps. **No bug.**
|
||||
|
||||
### Summary Table
|
||||
|
||||
| # | Location | Severity | Status | Description |
|
||||
|---|----------|----------|--------|-------------|
|
||||
| 1 | `rpc-manager.ts` `send("compact")` | Medium | **Fixed** | Removed redundant pre-check using private `sessionManager` field; `messageCount < 4` threshold mismatched library's actual compaction logic. Now delegates entirely to `AgentSession.compact()` which throws the proper error itself. |
|
||||
| 2 | `ChatWindow.tsx` `handleAgentEvent` | Medium | **Fixed** | Added `auto_compaction_start`/`auto_compaction_end` cases — `isCompacting` is now set/cleared via SSE during auto-compaction; on `auto_compaction_end` without error, `loadSession` is called to refresh the view. |
|
||||
| 3 | `ChatWindow.tsx` mount effect | Low | **Fixed** (via Bug 2 fix) | With `auto_compaction_end` handled, the button will clear when compaction finishes even if triggered during SSE reconnect on mount. |
|
||||
| 4 | `session-reader.ts` `buildSessionContext` | Low | **Fixed** | If `firstKeptEntryId` is not found in path (malformed data), now skips all pre-compaction entries cleanly instead of silently rendering zero messages. |
|
||||
| 5 | `agent-session.js` (library) | Info | Accepted | Manual compact does not emit `auto_compaction_start`/`end` events, only auto-compaction does; web UI has no per-event progress for manual compact. The blocking POST + "Compacting…" label is the correct UX. |
|
||||
|
||||
### Compact flow (post-fix)
|
||||
|
||||
1. User clicks **Compact** → `isCompacting = true`, button shows "Compacting…" (disabled)
|
||||
2. `POST /api/agent/[id]` with `{ type: "compact" }` → `AgentSession.compact()` runs (LLM call)
|
||||
3. On success → `loadSession(sid, true)` refreshes messages (summary injected at top of list, scroll goes to bottom)
|
||||
4. On error (already compacted, too small, no API key) → `compactError` shows inline in red; button re-enables immediately
|
||||
5. **Auto-compaction** (triggered server-side during streaming): `auto_compaction_start` SSE → `isCompacting = true`; `auto_compaction_end` SSE → `isCompacting = false` + reload if successful
|
||||
|
||||
---
|
||||
|
||||
## Development Rules
|
||||
|
||||
1. **Test every code change.** Run `node_modules/.bin/tsc --noEmit` and `node node_modules/next/dist/bin/next lint`. **Never run `next build` during development** — it pollutes `.next/` and causes `Cannot find module './xxx.js'` on next `npm run dev`.
|
||||
|
||||
2. **Keep AGENTS.md in sync.** Update when adding/changing features, API routes, component props, or design decisions.
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,61 @@
|
||||
import { resolveSessionPath, buildSessionInfo } from "@/lib/session-reader";
|
||||
import { getRpcSession, startRpcSession } from "@/lib/rpc-manager";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/agent/[id]/events - SSE stream of agent events
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
// Fast path: already-running session
|
||||
let session = getRpcSession(id);
|
||||
if (!session || !session.isAlive()) {
|
||||
const filePath = resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return new Response("Session not found", { status: 404 });
|
||||
}
|
||||
const info = buildSessionInfo(filePath);
|
||||
const cwd = info?.cwd ?? process.cwd();
|
||||
try {
|
||||
({ session } = await startRpcSession(id, filePath, cwd));
|
||||
} catch (error) {
|
||||
return new Response(`Failed to start agent: ${error}`, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encode = (data: unknown) => {
|
||||
const text = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
};
|
||||
|
||||
// Send initial connected event
|
||||
encode({ type: "connected", sessionId: id });
|
||||
|
||||
const unsubscribe = session.onEvent((event) => {
|
||||
encode(event);
|
||||
});
|
||||
|
||||
// Cleanup when client disconnects
|
||||
const cleanup = () => {
|
||||
unsubscribe();
|
||||
controller.close();
|
||||
};
|
||||
|
||||
// Detect client disconnect via abort signal
|
||||
req.signal?.addEventListener("abort", cleanup);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveSessionPath, buildSessionInfo } from "@/lib/session-reader";
|
||||
import { startRpcSession, getRpcSession } from "@/lib/rpc-manager";
|
||||
|
||||
// POST /api/agent/[id] - Send a command to an existing session
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const body = await req.json() as { type: string; [key: string]: unknown };
|
||||
|
||||
// Fast path: already-running session
|
||||
const existing = getRpcSession(id);
|
||||
if (existing?.isAlive()) {
|
||||
const result = await existing.send(body);
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
}
|
||||
|
||||
const filePath = resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const info = buildSessionInfo(filePath);
|
||||
const cwd = info?.cwd ?? process.cwd();
|
||||
|
||||
const { session } = await startRpcSession(id, filePath, cwd);
|
||||
const result = await session.send(body);
|
||||
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/agent/[id] - Get current agent state
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const session = getRpcSession(id);
|
||||
if (!session || !session.isAlive()) {
|
||||
return NextResponse.json({ running: false });
|
||||
}
|
||||
|
||||
const state = await session.send({ type: "get_state" });
|
||||
return NextResponse.json({ running: true, state });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync } from "fs";
|
||||
import { startRpcSession } from "@/lib/rpc-manager";
|
||||
|
||||
// POST /api/agent/new body: { cwd: string; type: string; message: string; ... }
|
||||
// Spawns a brand-new pi session and immediately sends the first command.
|
||||
// Returns { sessionId, data } where sessionId is pi's real session id.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: string; [key: string]: unknown };
|
||||
const { cwd, ...command } = body;
|
||||
|
||||
if (!cwd || typeof cwd !== "string") {
|
||||
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
|
||||
}
|
||||
if (!existsSync(cwd)) {
|
||||
return NextResponse.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Use a one-time key so startRpcSession's lock doesn't conflict with real session ids
|
||||
const tempKey = `__new__${Date.now()}`;
|
||||
const { session, realSessionId } = await startRpcSession(tempKey, "", cwd);
|
||||
const result = await session.send(command);
|
||||
|
||||
return NextResponse.json({ success: true, sessionId: realSessionId, data: result });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { listAllSessions } from "@/lib/session-reader";
|
||||
|
||||
const IGNORED_NAMES = new Set([
|
||||
"node_modules", ".git", ".next", "dist", "build", "__pycache__",
|
||||
".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache",
|
||||
"target", "vendor", ".DS_Store", ".git",
|
||||
]);
|
||||
|
||||
const IGNORED_SUFFIXES = [".pyc"];
|
||||
|
||||
const EXT_TO_LANGUAGE: Record<string, string> = {
|
||||
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
|
||||
mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby",
|
||||
go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift",
|
||||
c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp",
|
||||
html: "html", htm: "html", css: "css", scss: "css", less: "css",
|
||||
json: "json", jsonl: "json", yaml: "yaml", yml: "yaml",
|
||||
toml: "toml", xml: "xml", md: "markdown", mdx: "markdown",
|
||||
sh: "bash", bash: "bash", zsh: "bash", fish: "bash",
|
||||
sql: "sql", graphql: "graphql", gql: "graphql",
|
||||
dockerfile: "dockerfile", tf: "hcl", hcl: "hcl",
|
||||
env: "bash", gitignore: "bash", txt: "text",
|
||||
};
|
||||
|
||||
function getLanguage(filePath: string): string {
|
||||
const base = path.basename(filePath).toLowerCase();
|
||||
// Special full-name matches
|
||||
if (base === "dockerfile" || base.startsWith("dockerfile.")) return "dockerfile";
|
||||
if (base === ".env" || base.startsWith(".env.")) return "bash";
|
||||
if (base === "makefile" || base === "gnumakefile") return "makefile";
|
||||
const ext = base.split(".").pop() ?? "";
|
||||
return EXT_TO_LANGUAGE[ext] ?? "text";
|
||||
}
|
||||
|
||||
async function getAllowedRoots(): Promise<Set<string>> {
|
||||
const sessions = await listAllSessions();
|
||||
const roots = new Set<string>();
|
||||
for (const s of sessions) {
|
||||
if (s.cwd) roots.add(s.cwd);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function isPathAllowed(target: string, allowedRoots: Set<string>): boolean {
|
||||
const normalized = path.resolve(target);
|
||||
for (const root of allowedRoots) {
|
||||
const normalizedRoot = path.resolve(root);
|
||||
if (normalized === normalizedRoot || normalized.startsWith(normalizedRoot + path.sep)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const { path: segments } = await params;
|
||||
const filePath = "/" + segments.join("/");
|
||||
const type = request.nextUrl.searchParams.get("type") ?? "list";
|
||||
|
||||
const allowedRoots = await getAllowedRoots();
|
||||
if (!isPathAllowed(filePath, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(filePath);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (type === "read") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
// Limit file size to 2MB
|
||||
if (stat.size > 2 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: "File too large (>2MB)" }, { status: 413 });
|
||||
}
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const language = getLanguage(filePath);
|
||||
return NextResponse.json({ content, language, size: stat.size });
|
||||
}
|
||||
|
||||
// type === "list"
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
|
||||
}
|
||||
|
||||
const names = fs.readdirSync(filePath);
|
||||
const entries = names
|
||||
.filter((name) => !IGNORED_NAMES.has(name) && !IGNORED_SUFFIXES.some((s) => name.endsWith(s)))
|
||||
.map((name) => {
|
||||
const full = path.join(filePath, name);
|
||||
try {
|
||||
const s = fs.statSync(full);
|
||||
return {
|
||||
name,
|
||||
isDir: s.isDirectory(),
|
||||
size: s.isFile() ? s.size : 0,
|
||||
modified: s.mtime.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
// Dirs first, then files, both alphabetically
|
||||
if (a!.isDir !== b!.isDir) return a!.isDir ? -1 : 1;
|
||||
return a!.name.localeCompare(b!.name);
|
||||
});
|
||||
|
||||
return NextResponse.json({ entries, path: filePath });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getModelNameMap } from "@/lib/session-reader";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const map = getModelNameMap();
|
||||
return Response.json({ models: Object.fromEntries(map) });
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
resolveSessionPath,
|
||||
getSessionEntries,
|
||||
buildSessionContext,
|
||||
} from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const url = new URL(req.url);
|
||||
const leafId = url.searchParams.get("leafId") ?? undefined;
|
||||
|
||||
try {
|
||||
const filePath = resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const entries = getSessionEntries(filePath);
|
||||
const context = buildSessionContext(entries, leafId);
|
||||
|
||||
return NextResponse.json({ context });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { appendFileSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
import { join } from "path";
|
||||
import {
|
||||
resolveSessionPath,
|
||||
invalidateSessionPathCache,
|
||||
getSessionEntries,
|
||||
buildTree,
|
||||
buildSessionContext,
|
||||
getLeafId,
|
||||
buildSessionInfo,
|
||||
} from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const filePath = resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const entries = getSessionEntries(filePath);
|
||||
const tree = buildTree(entries);
|
||||
const leafId = getLeafId(entries);
|
||||
const context = buildSessionContext(entries, leafId);
|
||||
const info = buildSessionInfo(filePath);
|
||||
|
||||
return NextResponse.json({
|
||||
sessionId: id,
|
||||
filePath,
|
||||
info,
|
||||
tree,
|
||||
leafId,
|
||||
context,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/sessions/[id] body: { name: string }
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { name } = await req.json() as { name?: string };
|
||||
if (typeof name !== "string") {
|
||||
return NextResponse.json({ error: "name is required" }, { status: 400 });
|
||||
}
|
||||
const filePath = resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
const entries = getSessionEntries(filePath);
|
||||
const leafId = getLeafId(entries);
|
||||
const entry = {
|
||||
type: "session_info",
|
||||
id: randomUUID(),
|
||||
parentId: leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
name: name.trim(),
|
||||
};
|
||||
appendFileSync(filePath, JSON.stringify(entry) + "\n");
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/sessions/[id]
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const filePath = resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Read header before deleting to get parentSession path
|
||||
const firstLine = readFileSync(filePath, "utf8").split("\n")[0];
|
||||
let parentSessionPath: string | undefined;
|
||||
try {
|
||||
const header = JSON.parse(firstLine) as { type?: string; parentSession?: string };
|
||||
if (header.type === "session") parentSessionPath = header.parentSession;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Re-attach all direct children to this session's parent (cascade re-parent)
|
||||
// Scan sibling files in the same directory
|
||||
const dir = filePath.replace(/\\/g, "/").split("/").slice(0, -1).join("/");
|
||||
try {
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl") && join(dir, f) !== filePath);
|
||||
for (const file of files) {
|
||||
const childPath = join(dir, file);
|
||||
try {
|
||||
const content = readFileSync(childPath, "utf8");
|
||||
const lines = content.split("\n");
|
||||
const header = JSON.parse(lines[0]) as { type?: string; parentSession?: string };
|
||||
if (header.type === "session" && header.parentSession === filePath) {
|
||||
// Rewrite header with new parentSession
|
||||
header.parentSession = parentSessionPath;
|
||||
lines[0] = JSON.stringify(header);
|
||||
writeFileSync(childPath, lines.join("\n"));
|
||||
}
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
} catch { /* skip if dir unreadable */ }
|
||||
|
||||
unlinkSync(filePath);
|
||||
invalidateSessionPathCache(id);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// This route is no longer used — new sessions are created fully client-side.
|
||||
// Kept as a no-op for reference.
|
||||
export async function POST() {
|
||||
return new Response("Not used", { status: 410 });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listAllSessions } from "@/lib/session-reader";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const sessions = await listAllSessions();
|
||||
return NextResponse.json({ sessions });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+110
@@ -0,0 +1,110 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-panel: #f5f5f5;
|
||||
--bg-hover: #eeeeee;
|
||||
--bg-selected: #e8e8e8;
|
||||
--border: #e0e0e0;
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #6b7280;
|
||||
--text-dim: #9ca3af;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--user-bg: #eff6ff;
|
||||
--assistant-bg: #ffffff;
|
||||
--tool-bg: #f9fafb;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
pre, code {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* react-markdown output styles */
|
||||
.markdown-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
}
|
||||
.markdown-body p { margin: 0 0 8px; }
|
||||
.markdown-body p:last-child { margin-bottom: 0; }
|
||||
.markdown-body h1, .markdown-body h2, .markdown-body h3,
|
||||
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
|
||||
font-weight: 600;
|
||||
margin: 10px 0 4px;
|
||||
color: var(--text);
|
||||
}
|
||||
.markdown-body h1 { font-size: 1.15em; }
|
||||
.markdown-body h2 { font-size: 1.05em; }
|
||||
.markdown-body h3 { font-size: 0.95em; }
|
||||
.markdown-body ul, .markdown-body ol {
|
||||
padding-left: 20px;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
.markdown-body li { margin: 2px 0; }
|
||||
.markdown-body a { color: var(--accent); text-decoration: underline; }
|
||||
.markdown-body blockquote {
|
||||
border-left: 3px solid var(--border);
|
||||
margin: 4px 0;
|
||||
padding: 2px 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.markdown-body th, .markdown-body td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 5px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.markdown-body th { background: var(--bg-panel); font-weight: 600; }
|
||||
.markdown-body hr { border: none; border-top: 1px solid var(--border); margin: 10px 0; }
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pi Agent",
|
||||
description: "Pi Coding Agent Web Interface",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense>
|
||||
<AppShell />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { SessionSidebar } from "./SessionSidebar";
|
||||
import { ChatWindow } from "./ChatWindow";
|
||||
import { FileViewer } from "./FileViewer";
|
||||
import { TabBar, type Tab } from "./TabBar";
|
||||
import type { SessionInfo } from "@/lib/types";
|
||||
|
||||
const CHAT_TAB: Tab = { id: "chat", type: "chat", label: "Chat" };
|
||||
|
||||
export function AppShell() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [selectedSession, setSelectedSession] = useState<SessionInfo | null>(null);
|
||||
// When user clicks +, we only store the cwd — no fake session id
|
||||
const [newSessionCwd, setNewSessionCwd] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [sessionKey, setSessionKey] = useState(0);
|
||||
const [explorerRefreshKey, setExplorerRefreshKey] = useState(0);
|
||||
|
||||
const [initialSessionId] = useState<string | null>(() => searchParams.get("session"));
|
||||
|
||||
// Tab management
|
||||
const [tabs, setTabs] = useState<Tab[]>([CHAT_TAB]);
|
||||
const [activeTabId, setActiveTabId] = useState<string>("chat");
|
||||
|
||||
const handleSelectSession = useCallback((session: SessionInfo) => {
|
||||
setNewSessionCwd(null);
|
||||
setSelectedSession(session);
|
||||
setSessionKey((k) => k + 1);
|
||||
router.replace(`?session=${encodeURIComponent(session.id)}`, { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
const handleNewSession = useCallback((_sessionId: string, cwd: string) => {
|
||||
setSelectedSession(null);
|
||||
setNewSessionCwd(cwd);
|
||||
setSessionKey((k) => k + 1);
|
||||
router.replace("/", { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
// Called by ChatWindow when a new session gets its real id from pi
|
||||
const handleSessionCreated = useCallback((session: SessionInfo) => {
|
||||
setNewSessionCwd(null);
|
||||
setSelectedSession(session);
|
||||
setRefreshKey((k) => k + 1);
|
||||
router.replace(`?session=${encodeURIComponent(session.id)}`, { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
const handleAgentEnd = useCallback(() => {
|
||||
setRefreshKey((k) => k + 1);
|
||||
setExplorerRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
const handleSessionForked = useCallback((newSessionId: string) => {
|
||||
setRefreshKey((k) => k + 1);
|
||||
setSessionKey((k) => k + 1);
|
||||
setNewSessionCwd(null);
|
||||
setSelectedSession((prev) => ({
|
||||
...(prev ?? { path: "", cwd: "", created: "", modified: "", messageCount: 0, firstMessage: "" }),
|
||||
id: newSessionId,
|
||||
}));
|
||||
router.replace(`?session=${encodeURIComponent(newSessionId)}`, { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
const handleSessionDeleted = useCallback((sessionId: string) => {
|
||||
setRefreshKey((k) => k + 1);
|
||||
if (selectedSession?.id === sessionId) {
|
||||
setSelectedSession(null);
|
||||
setNewSessionCwd(null);
|
||||
router.replace("/", { scroll: false });
|
||||
}
|
||||
}, [selectedSession, router]);
|
||||
|
||||
const handleOpenFile = useCallback((filePath: string, fileName: string) => {
|
||||
const tabId = `file:${filePath}`;
|
||||
setTabs((prev) => {
|
||||
if (prev.find((t) => t.id === tabId)) return prev;
|
||||
return [...prev, { id: tabId, type: "file", label: fileName, filePath }];
|
||||
});
|
||||
setActiveTabId(tabId);
|
||||
}, []);
|
||||
|
||||
const handleCloseTab = useCallback((tabId: string) => {
|
||||
setTabs((prev) => prev.filter((t) => t.id !== tabId));
|
||||
setActiveTabId((cur) => (cur === tabId ? "chat" : cur));
|
||||
}, []);
|
||||
|
||||
const showChat = selectedSession !== null || newSessionCwd !== null;
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId) ?? CHAT_TAB;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", overflow: "hidden", background: "var(--bg)" }}>
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
style={{
|
||||
width: 260,
|
||||
minWidth: 260,
|
||||
borderRight: "1px solid var(--border)",
|
||||
background: "var(--bg-panel)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<SessionSidebar
|
||||
selectedSessionId={selectedSession?.id ?? null}
|
||||
onSelectSession={handleSelectSession}
|
||||
onNewSession={handleNewSession}
|
||||
initialSessionId={initialSessionId}
|
||||
refreshKey={refreshKey}
|
||||
onSessionDeleted={handleSessionDeleted}
|
||||
selectedCwd={selectedSession?.cwd ?? newSessionCwd ?? null}
|
||||
onOpenFile={handleOpenFile}
|
||||
explorerRefreshKey={explorerRefreshKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Tab bar */}
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
onSelectTab={setActiveTabId}
|
||||
onCloseTab={handleCloseTab}
|
||||
/>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
||||
{/* Chat tab — always mounted, hidden when another tab is active */}
|
||||
<div style={{ position: "absolute", inset: 0, display: activeTab.type === "chat" ? "flex" : "none", flexDirection: "column" }}>
|
||||
{showChat ? (
|
||||
<ChatWindow
|
||||
key={sessionKey}
|
||||
session={selectedSession}
|
||||
newSessionCwd={newSessionCwd}
|
||||
onAgentEnd={handleAgentEnd}
|
||||
onSessionCreated={handleSessionCreated}
|
||||
onSessionForked={handleSessionForked}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
Select a session from the sidebar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File tabs */}
|
||||
{activeTab.type === "file" && activeTab.filePath && (
|
||||
<div style={{ position: "absolute", inset: 0 }}>
|
||||
<FileViewer filePath={activeTab.filePath} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import type { SessionEntry, SessionTreeNode } from "@/lib/types";
|
||||
|
||||
interface Props {
|
||||
tree: SessionTreeNode[];
|
||||
activeLeafId: string | null;
|
||||
onLeafChange: (leafId: string | null) => void;
|
||||
}
|
||||
|
||||
// Find the set of entry IDs on the path from root to activeLeafId
|
||||
function buildActivePath(nodes: SessionTreeNode[], targetId: string | null): Set<string> {
|
||||
if (!targetId) return new Set();
|
||||
function search(nodes: SessionTreeNode[], path: string[]): string[] | null {
|
||||
for (const node of nodes) {
|
||||
const next = [...path, node.entry.id];
|
||||
if (node.entry.id === targetId) return next;
|
||||
const found = search(node.children, next);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return new Set(search(nodes, []) ?? []);
|
||||
}
|
||||
|
||||
// Compress a linear chain into the first branching/leaf node.
|
||||
// Returns the representative node to display, plus a count of skipped nodes.
|
||||
function compress(node: SessionTreeNode): { node: SessionTreeNode; skipped: number } {
|
||||
let current = node;
|
||||
let skipped = 0;
|
||||
while (current.children.length === 1) {
|
||||
current = current.children[0];
|
||||
skipped++;
|
||||
}
|
||||
return { node: current, skipped };
|
||||
}
|
||||
|
||||
function getLabel(entry: SessionEntry): string {
|
||||
if (entry.type === "message" && "message" in entry) {
|
||||
const msg = entry.message as { role: string; content: unknown };
|
||||
const content = msg.content;
|
||||
let text = "";
|
||||
if (typeof content === "string") {
|
||||
text = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
text = content
|
||||
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join(" ");
|
||||
}
|
||||
if (text.length > 40) text = text.slice(0, 40) + "…";
|
||||
if (text) return text;
|
||||
if (msg.role === "assistant") return "[assistant]";
|
||||
}
|
||||
return entry.type;
|
||||
}
|
||||
|
||||
// Does the tree have any branching at all?
|
||||
function hasBranch(nodes: SessionTreeNode[]): boolean {
|
||||
for (const node of nodes) {
|
||||
if (node.children.length > 1) return true;
|
||||
if (hasBranch(node.children)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: SessionTreeNode;
|
||||
activePathIds: Set<string>;
|
||||
depth: number;
|
||||
isLast: boolean;
|
||||
parentLines: boolean[]; // whether ancestor at each depth has more siblings after
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
function TreeNodeView({ node, activePathIds, depth, isLast, parentLines, onSelect }: TreeNodeProps) {
|
||||
const { node: rep, skipped } = compress(node);
|
||||
const isActive = activePathIds.has(rep.entry.id);
|
||||
const isOnPath = activePathIds.has(node.entry.id) || activePathIds.has(rep.entry.id);
|
||||
const label = getLabel(rep.entry);
|
||||
const role = rep.entry.type === "message" && "message" in rep.entry
|
||||
? (rep.entry.message as { role: string }).role
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* This node row */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: 24,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => onSelect(rep.entry.id)}
|
||||
>
|
||||
{/* Indent guide lines */}
|
||||
{parentLines.map((hasLine, i) => (
|
||||
<div key={i} style={{ width: 16, flexShrink: 0, position: "relative", height: "100%", alignSelf: "stretch" }}>
|
||||
{hasLine && (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: 7,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 1,
|
||||
background: "var(--border)",
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Branch connector */}
|
||||
<div style={{ width: 16, flexShrink: 0, position: "relative", height: "100%", alignSelf: "stretch" }}>
|
||||
{/* vertical line up (to parent) */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: 7,
|
||||
top: 0,
|
||||
bottom: isLast ? "50%" : 0,
|
||||
width: 1,
|
||||
background: "var(--border)",
|
||||
}} />
|
||||
{/* horizontal line to node */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: 7,
|
||||
top: "50%",
|
||||
width: 9,
|
||||
height: 1,
|
||||
background: "var(--border)",
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Node dot */}
|
||||
<div style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
background: isActive ? "var(--accent)" : isOnPath ? "var(--text-muted)" : "var(--border)",
|
||||
border: isActive ? "none" : "1px solid var(--text-dim)",
|
||||
marginRight: 6,
|
||||
transition: "background 0.12s",
|
||||
}} />
|
||||
|
||||
{/* Role badge */}
|
||||
{role && (
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
fontFamily: "var(--font-mono)",
|
||||
color: role === "user" ? "var(--accent)" : "var(--text-dim)",
|
||||
background: role === "user" ? "rgba(37,99,235,0.08)" : "var(--bg-hover)",
|
||||
border: `1px solid ${role === "user" ? "rgba(37,99,235,0.2)" : "var(--border)"}`,
|
||||
borderRadius: 3,
|
||||
padding: "0 4px",
|
||||
marginRight: 5,
|
||||
flexShrink: 0,
|
||||
lineHeight: "16px",
|
||||
}}>
|
||||
{role === "user" ? "U" : "A"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Skipped indicator */}
|
||||
{skipped > 0 && (
|
||||
<span style={{ fontSize: 10, color: "var(--text-dim)", marginRight: 5, flexShrink: 0 }}>
|
||||
+{skipped}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
color: isActive ? "var(--text)" : isOnPath ? "var(--text-muted)" : "var(--text-dim)",
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{rep.children.map((child, idx) => (
|
||||
<TreeNodeView
|
||||
key={child.entry.id}
|
||||
node={child}
|
||||
activePathIds={activePathIds}
|
||||
depth={depth + 1}
|
||||
isLast={idx === rep.children.length - 1}
|
||||
parentLines={[...parentLines, !isLast]}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchNavigator({ tree, activeLeafId, onLeafChange }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const activePathIds = useMemo(
|
||||
() => buildActivePath(tree, activeLeafId),
|
||||
[tree, activeLeafId]
|
||||
);
|
||||
|
||||
const handleSelect = useCallback((id: string) => {
|
||||
onLeafChange(id);
|
||||
setOpen(false);
|
||||
}, [onLeafChange]);
|
||||
|
||||
// Only show if there's actual branching
|
||||
if (!hasBranch(tree)) return null;
|
||||
|
||||
// Find first meaningful node (skip pure linear prefix)
|
||||
const { node: firstNode } = tree.length > 0 ? compress(tree[0]) : { node: tree[0] };
|
||||
if (!firstNode || firstNode.children.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-panel)", flexShrink: 0 }}>
|
||||
{/* Header toggle */}
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
width: "100%",
|
||||
padding: "5px 12px",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 11,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{/* branch icon */}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--accent)", flexShrink: 0 }}>
|
||||
<line x1="6" y1="3" x2="6" y2="15" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
<span style={{ color: "var(--text-muted)" }}>Branches</span>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--text-dim)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: 2, transform: open ? "rotate(180deg)" : "none", transition: "transform 0.15s" }}>
|
||||
<polyline points="2 3.5 5 6.5 8 3.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Tree panel */}
|
||||
{open && (
|
||||
<div style={{ padding: "4px 12px 8px 12px", maxHeight: 260, overflowY: "auto" }}>
|
||||
{firstNode.children.map((child, idx) => (
|
||||
<TreeNodeView
|
||||
key={child.entry.id}
|
||||
node={child}
|
||||
activePathIds={activePathIds}
|
||||
depth={0}
|
||||
isLast={idx === firstNode.children.length - 1}
|
||||
parentLines={[]}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, useEffect, KeyboardEvent } from "react";
|
||||
|
||||
interface ModelOption {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSend: (message: string) => void;
|
||||
onAbort: () => void;
|
||||
onSteer?: (message: string) => void;
|
||||
onFollowUp?: (message: string) => void;
|
||||
isStreaming: boolean;
|
||||
model?: { provider: string; modelId: string } | null;
|
||||
modelNames?: Record<string, string>;
|
||||
onModelChange?: (provider: string, modelId: string) => void;
|
||||
thinkingLevel?: string;
|
||||
onThinkingLevelChange?: (level: string) => void;
|
||||
onCompact?: () => void;
|
||||
onAbortCompaction?: () => void;
|
||||
isCompacting?: boolean;
|
||||
compactError?: string | null;
|
||||
onToolsClick?: () => void;
|
||||
}
|
||||
|
||||
const THINKING_LEVELS = ["off", "low", "high"] as const;
|
||||
|
||||
export function ChatInput({
|
||||
onSend, onAbort, onSteer, onFollowUp, isStreaming, model, modelNames, onModelChange,
|
||||
thinkingLevel, onThinkingLevelChange,
|
||||
onCompact, onAbortCompaction, isCompacting, compactError, onToolsClick,
|
||||
}: Props) {
|
||||
const [value, setValue] = useState("");
|
||||
const [queueMode, setQueueMode] = useState<"steer" | "followup">("steer");
|
||||
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const msg = value.trim();
|
||||
if (!msg || isStreaming) return;
|
||||
onSend(msg);
|
||||
setValue("");
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
}, [value, isStreaming, onSend]);
|
||||
|
||||
const handleQueueSend = useCallback(() => {
|
||||
const msg = value.trim();
|
||||
if (!msg) return;
|
||||
if (queueMode === "steer" && onSteer) {
|
||||
onSteer(msg);
|
||||
} else if (queueMode === "followup" && onFollowUp) {
|
||||
onFollowUp(msg);
|
||||
}
|
||||
setValue("");
|
||||
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
||||
}, [value, queueMode, onSteer, onFollowUp]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (isStreaming && (onSteer || onFollowUp)) {
|
||||
handleQueueSend();
|
||||
} else {
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
},
|
||||
[isStreaming, onSteer, onFollowUp, handleQueueSend, handleSend]
|
||||
);
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
|
||||
}, []);
|
||||
|
||||
// Build model options from modelNames map + current model provider
|
||||
const modelOptions: ModelOption[] = Object.entries(modelNames ?? {}).map(([modelId, name]) => ({
|
||||
provider: model?.provider ?? "unknown",
|
||||
modelId,
|
||||
name,
|
||||
}));
|
||||
|
||||
const currentName = model ? (modelNames?.[model.modelId] ?? model.modelId) : null;
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setModelDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderTop: "1px solid var(--border)",
|
||||
background: "var(--bg-panel)",
|
||||
padding: "10px 16px 12px",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 820, margin: "0 auto" }}>
|
||||
{/* Main input */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
background: "var(--bg)",
|
||||
border: `1px solid ${isStreaming && (onSteer || onFollowUp)
|
||||
? (queueMode === "steer" ? "rgba(234,179,8,0.4)" : "rgba(99,102,241,0.4)")
|
||||
: "var(--border)"}`,
|
||||
borderRadius: 8,
|
||||
padding: "8px 8px 8px 12px",
|
||||
transition: "border-color 0.15s",
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder={
|
||||
isStreaming && (onSteer || onFollowUp)
|
||||
? (queueMode === "steer" ? "Inject guidance mid-run…" : "Queue a message for after agent finishes…")
|
||||
: isStreaming ? "Agent is running…"
|
||||
: "Message…"
|
||||
}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "none",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
color: "var(--text)",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.6,
|
||||
fontFamily: "inherit",
|
||||
minHeight: 24,
|
||||
maxHeight: 200,
|
||||
overflow: "auto",
|
||||
}}
|
||||
/>
|
||||
|
||||
{isStreaming ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, flexShrink: 0 }}>
|
||||
{/* Steer / Follow-up mode toggle + send, only when callbacks available */}
|
||||
{(onSteer || onFollowUp) && (
|
||||
<>
|
||||
<div style={{ display: "flex", border: "1px solid var(--border)", borderRadius: 5, overflow: "hidden" }}>
|
||||
{(["steer", "followup"] as const).map((mode) => {
|
||||
const active = queueMode === mode;
|
||||
const color = mode === "steer" ? "rgba(234,179,8,0.9)" : "rgba(99,102,241,0.9)";
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setQueueMode(mode)}
|
||||
title={mode === "steer" ? "Interrupt: inject guidance mid-run" : "Follow-up: queue message for after agent finishes"}
|
||||
style={{
|
||||
padding: "3px 8px",
|
||||
background: active ? (mode === "steer" ? "rgba(234,179,8,0.12)" : "rgba(99,102,241,0.12)") : "none",
|
||||
border: "none",
|
||||
borderRight: mode === "steer" ? "1px solid var(--border)" : "none",
|
||||
color: active ? color : "var(--text-dim)",
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
fontWeight: active ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{mode === "steer" ? "Steer" : "Follow-up"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleQueueSend}
|
||||
disabled={!value.trim()}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 5,
|
||||
padding: "5px 12px",
|
||||
background: value.trim()
|
||||
? (queueMode === "steer" ? "rgba(234,179,8,0.15)" : "rgba(99,102,241,0.12)")
|
||||
: "var(--bg-panel)",
|
||||
border: `1px solid ${queueMode === "steer" ? "rgba(234,179,8,0.35)" : "rgba(99,102,241,0.35)"}`,
|
||||
borderRadius: 7,
|
||||
color: value.trim()
|
||||
? (queueMode === "steer" ? "rgba(234,179,8,0.9)" : "rgba(99,102,241,0.9)")
|
||||
: "var(--text-dim)",
|
||||
cursor: value.trim() ? "pointer" : "not-allowed",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* Stop button */}
|
||||
<button
|
||||
onClick={onAbort}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
padding: "7px 14px",
|
||||
background: "rgba(239,68,68,0.08)",
|
||||
border: "1px solid rgba(239,68,68,0.35)",
|
||||
borderRadius: 8,
|
||||
color: "#ef4444",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
||||
<rect x="2" y="2" width="9" height="9" rx="2" fill="currentColor" />
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!value.trim()}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
padding: "7px 14px",
|
||||
background: value.trim() ? "var(--accent)" : "var(--bg-panel)",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
color: value.trim() ? "#fff" : "var(--text-dim)",
|
||||
cursor: value.trim() ? "pointer" : "not-allowed",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "-0.01em",
|
||||
boxShadow: value.trim() ? "0 1px 3px rgba(37,99,235,0.25)" : "none",
|
||||
transition: "background 0.15s, box-shadow 0.15s",
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="2" y1="7" x2="11" y2="7" />
|
||||
<polyline points="7.5 3 12 7 7.5 11" />
|
||||
</svg>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div style={{ marginTop: 6, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 6 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{/* Model selector */}
|
||||
{modelOptions.length > 0 && currentName && onModelChange && (
|
||||
<div ref={dropdownRef} style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setModelDropdownOpen((v) => !v)}
|
||||
disabled={isStreaming}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 5,
|
||||
padding: "3px 8px",
|
||||
background: "none",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 5,
|
||||
color: "var(--text-muted)",
|
||||
cursor: isStreaming ? "not-allowed" : "pointer",
|
||||
fontSize: 11,
|
||||
opacity: isStreaming ? 0.5 : 1,
|
||||
transition: "border-color 0.12s, color 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isStreaming) return;
|
||||
e.currentTarget.style.borderColor = "rgba(37,99,235,0.4)";
|
||||
e.currentTarget.style.color = "var(--accent)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.color = "var(--text-muted)";
|
||||
}}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<rect x="9" y="9" width="6" height="6" />
|
||||
<line x1="9" y1="1" x2="9" y2="4" /><line x1="15" y1="1" x2="15" y2="4" />
|
||||
<line x1="9" y1="20" x2="9" y2="23" /><line x1="15" y1="20" x2="15" y2="23" />
|
||||
<line x1="20" y1="9" x2="23" y2="9" /><line x1="20" y1="14" x2="23" y2="14" />
|
||||
<line x1="1" y1="9" x2="4" y2="9" /><line x1="1" y1="14" x2="4" y2="14" />
|
||||
</svg>
|
||||
{currentName}
|
||||
<svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{ transform: modelDropdownOpen ? "rotate(180deg)" : "none", transition: "transform 0.15s" }}>
|
||||
<polyline points="2 3.5 5 6.5 8 3.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{modelDropdownOpen && (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 6px)",
|
||||
left: 0,
|
||||
zIndex: 100,
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 -4px 16px rgba(0,0,0,0.10)",
|
||||
overflow: "hidden",
|
||||
minWidth: 160,
|
||||
}}>
|
||||
{modelOptions.map((opt) => {
|
||||
const isActive = opt.modelId === model?.modelId;
|
||||
return (
|
||||
<button
|
||||
key={opt.modelId}
|
||||
onClick={() => {
|
||||
setModelDropdownOpen(false);
|
||||
if (!isActive) onModelChange(opt.provider, opt.modelId);
|
||||
}}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: isActive ? "var(--bg-selected)" : "none",
|
||||
border: "none",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
color: isActive ? "var(--text)" : "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
textAlign: "left",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "var(--bg-hover)"; }}
|
||||
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "none"; }}
|
||||
>
|
||||
{isActive ? (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="1.5 5 4 7.5 8.5 2.5" />
|
||||
</svg>
|
||||
) : <span style={{ width: 10, flexShrink: 0 }} />}
|
||||
{opt.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thinking level selector */}
|
||||
{onThinkingLevelChange && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 2, border: "1px solid var(--border)", borderRadius: 5, overflow: "hidden" }}>
|
||||
{THINKING_LEVELS.map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
onClick={() => !isStreaming && onThinkingLevelChange(lvl)}
|
||||
disabled={isStreaming}
|
||||
style={{
|
||||
padding: "3px 7px",
|
||||
background: thinkingLevel === lvl ? "var(--bg-selected)" : "none",
|
||||
border: "none",
|
||||
borderRight: lvl !== "high" ? "1px solid var(--border)" : "none",
|
||||
color: thinkingLevel === lvl ? "var(--accent)" : "var(--text-dim)",
|
||||
cursor: isStreaming ? "not-allowed" : "pointer",
|
||||
fontSize: 11,
|
||||
fontWeight: thinkingLevel === lvl ? 600 : 400,
|
||||
opacity: isStreaming ? 0.5 : 1,
|
||||
}}
|
||||
title={`Thinking: ${lvl}`}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{/* Tools button */}
|
||||
{onToolsClick && (
|
||||
<button
|
||||
onClick={onToolsClick}
|
||||
disabled={isStreaming}
|
||||
title="Configure active tools"
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
padding: "3px 8px",
|
||||
background: "none",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 5,
|
||||
color: "var(--text-muted)",
|
||||
cursor: isStreaming ? "not-allowed" : "pointer",
|
||||
fontSize: 11,
|
||||
opacity: isStreaming ? 0.5 : 1,
|
||||
transition: "border-color 0.12s, color 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isStreaming) return;
|
||||
e.currentTarget.style.borderColor = "rgba(37,99,235,0.4)";
|
||||
e.currentTarget.style.color = "var(--accent)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.color = "var(--text-muted)";
|
||||
}}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
Tools
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Compact / Abort compaction button */}
|
||||
{onCompact && (
|
||||
<div style={{ position: "relative" }}>
|
||||
{compactError && (
|
||||
<div style={{
|
||||
position: "absolute", bottom: "calc(100% + 6px)", right: 0,
|
||||
background: "#1f2937", color: "#f87171",
|
||||
fontSize: 11, padding: "4px 8px", borderRadius: 5,
|
||||
whiteSpace: "nowrap", pointerEvents: "none",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
|
||||
zIndex: 50,
|
||||
}}>
|
||||
{compactError}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={isCompacting ? onAbortCompaction : onCompact}
|
||||
disabled={isStreaming && !isCompacting}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
padding: "3px 8px",
|
||||
background: "none",
|
||||
border: `1px solid ${isCompacting ? "rgba(239,68,68,0.4)" : "var(--border)"}`,
|
||||
borderRadius: 5,
|
||||
color: isCompacting ? "#ef4444" : "var(--text-muted)",
|
||||
cursor: (isStreaming && !isCompacting) ? "not-allowed" : "pointer",
|
||||
fontSize: 11,
|
||||
opacity: (isStreaming && !isCompacting) ? 0.5 : 1,
|
||||
transition: "border-color 0.12s, color 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isStreaming && !isCompacting) return;
|
||||
e.currentTarget.style.borderColor = isCompacting ? "rgba(239,68,68,0.7)" : "rgba(37,99,235,0.4)";
|
||||
e.currentTarget.style.color = isCompacting ? "#ef4444" : "var(--accent)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = isCompacting ? "rgba(239,68,68,0.4)" : "var(--border)";
|
||||
e.currentTarget.style.color = isCompacting ? "#ef4444" : "var(--text-muted)";
|
||||
}}
|
||||
title={isCompacting ? "Abort compaction" : "Compact context"}
|
||||
>
|
||||
{isCompacting ? (
|
||||
<>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<rect x="2" y="2" width="6" height="6" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
Compacting…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="4 14 10 14 10 20" />
|
||||
<polyline points="20 10 14 10 14 4" />
|
||||
<line x1="10" y1="14" x2="3" y2="21" />
|
||||
<line x1="21" y1="3" x2="14" y2="10" />
|
||||
</svg>
|
||||
Compact
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback, useMemo, RefObject } from "react";
|
||||
import type { AgentMessage, AssistantMessage, TextContent } from "@/lib/types";
|
||||
|
||||
interface Props {
|
||||
messages: AgentMessage[];
|
||||
streamingMessage: Partial<AgentMessage> | null;
|
||||
scrollContainer: RefObject<HTMLDivElement | null>;
|
||||
messageRefs: RefObject<(HTMLDivElement | null)[]>;
|
||||
}
|
||||
|
||||
const MINIMAP_WIDTH = 36;
|
||||
|
||||
function getMessagePreview(msg: AgentMessage | Partial<AgentMessage>): string {
|
||||
if (msg.role === "user") {
|
||||
const content = msg.content;
|
||||
if (typeof content === "string") return content.slice(0, 200);
|
||||
if (Array.isArray(content)) {
|
||||
return (content as { type: string; text?: string }[])
|
||||
.filter((b) => b.type === "text" && b.text)
|
||||
.map((b) => b.text!)
|
||||
.join("\n")
|
||||
.slice(0, 200);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (msg.role === "assistant") {
|
||||
const blocks = (msg as Partial<AssistantMessage>).content ?? [];
|
||||
const text = blocks
|
||||
.filter((b): b is TextContent => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join(" ");
|
||||
if (text) return text.slice(0, 200);
|
||||
const toolNames = blocks
|
||||
.filter((b) => b.type === "toolCall")
|
||||
.map((b) => (b as { type: string; toolName: string }).toolName);
|
||||
if (toolNames.length) return toolNames.join(", ");
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getNodeColor(msg: AgentMessage | Partial<AgentMessage>): { bg: string; border: string } {
|
||||
if (msg.role === "user") {
|
||||
return { bg: "rgba(37,99,235,0.18)", border: "rgba(37,99,235,0.7)" };
|
||||
}
|
||||
return { bg: "rgba(107,114,128,0.12)", border: "rgba(107,114,128,0.5)" };
|
||||
}
|
||||
|
||||
function hasTextContent(msg: AgentMessage | Partial<AgentMessage>): boolean {
|
||||
if (msg.role === "user") return true;
|
||||
if (msg.role === "assistant") {
|
||||
const blocks = (msg as Partial<AssistantMessage>).content ?? [];
|
||||
return blocks.some((b) => b.type === "text");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface NodeInfo {
|
||||
topRatio: number; // 0–1 within total scroll height
|
||||
heightRatio: number;
|
||||
msg: AgentMessage | Partial<AgentMessage>;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function ChatMinimap({ messages, streamingMessage, scrollContainer, messageRefs }: Props) {
|
||||
const [scrollRatio, setScrollRatio] = useState(0);
|
||||
const [viewportRatio, setViewportRatio] = useState(1);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [minimapHovered, setMinimapHovered] = useState(false);
|
||||
const [mouseYRatio, setMouseYRatio] = useState<number | null>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const allMessages = useMemo(
|
||||
() => (streamingMessage ? [...messages, streamingMessage] : messages) as (AgentMessage | Partial<AgentMessage>)[],
|
||||
[messages, streamingMessage]
|
||||
);
|
||||
|
||||
const updatePositions = useCallback(() => {
|
||||
const scrollEl = scrollContainer.current;
|
||||
if (!scrollEl) return;
|
||||
|
||||
const totalH = scrollEl.scrollHeight;
|
||||
const clientH = scrollEl.clientHeight;
|
||||
const scrollable = totalH - clientH;
|
||||
|
||||
setVisible(scrollable > 20);
|
||||
if (scrollable <= 0) {
|
||||
setScrollRatio(0);
|
||||
setViewportRatio(1);
|
||||
} else {
|
||||
setScrollRatio(scrollEl.scrollTop / scrollable);
|
||||
setViewportRatio(clientH / totalH);
|
||||
}
|
||||
|
||||
// Build node positions from real DOM refs
|
||||
const refs = messageRefs.current;
|
||||
const newNodes: NodeInfo[] = [];
|
||||
let refIndex = 0;
|
||||
|
||||
for (let i = 0; i < allMessages.length; i++) {
|
||||
const msg = allMessages[i];
|
||||
if (msg.role !== "user" && msg.role !== "assistant") continue;
|
||||
if (!hasTextContent(msg)) continue;
|
||||
|
||||
const el = refs?.[refIndex];
|
||||
refIndex++;
|
||||
|
||||
if (el && totalH > 0) {
|
||||
const top = el.offsetTop;
|
||||
const h = el.offsetHeight;
|
||||
newNodes.push({
|
||||
topRatio: top / totalH,
|
||||
heightRatio: h / totalH,
|
||||
msg,
|
||||
index: newNodes.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
setNodes(newNodes);
|
||||
}, [scrollContainer, messageRefs, allMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollContainer.current;
|
||||
if (!el) return;
|
||||
el.addEventListener("scroll", updatePositions, { passive: true });
|
||||
const ro = new ResizeObserver(updatePositions);
|
||||
ro.observe(el);
|
||||
// Also observe the scroll content for height changes
|
||||
if (el.firstElementChild) ro.observe(el.firstElementChild);
|
||||
updatePositions();
|
||||
return () => {
|
||||
el.removeEventListener("scroll", updatePositions);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [scrollContainer, updatePositions]);
|
||||
|
||||
// Re-measure when messages change (new messages arrive)
|
||||
useEffect(() => {
|
||||
const t = setTimeout(updatePositions, 50);
|
||||
return () => clearTimeout(t);
|
||||
}, [messages.length, streamingMessage, updatePositions]);
|
||||
|
||||
const scrollToMinimapRatio = useCallback((viewportTopRatio: number) => {
|
||||
const el = scrollContainer.current;
|
||||
if (!el) return;
|
||||
const scrollable = el.scrollHeight - el.clientHeight;
|
||||
if (scrollable <= 0) return;
|
||||
const clamped = Math.max(0, Math.min(1 - viewportRatio, viewportTopRatio));
|
||||
el.scrollTop = (clamped / (1 - viewportRatio)) * scrollable;
|
||||
}, [scrollContainer, viewportRatio]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!visible) return;
|
||||
// Don't initiate drag if clicking a node button
|
||||
if ((e.target as HTMLElement).closest("[data-minimap-node]")) return;
|
||||
draggingRef.current = true;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickRatio = (e.clientY - rect.top) / rect.height;
|
||||
const grabOffset = clickRatio - scrollRatio * (1 - viewportRatio);
|
||||
const insideBox = grabOffset >= 0 && grabOffset <= viewportRatio;
|
||||
const offset = insideBox ? grabOffset : viewportRatio / 2;
|
||||
|
||||
scrollToMinimapRatio(clickRatio - offset);
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
const r = (ev.clientY - rect.top) / rect.height;
|
||||
scrollToMinimapRatio(r - offset);
|
||||
};
|
||||
const onUp = () => {
|
||||
draggingRef.current = false;
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
}, [visible, viewportRatio, scrollRatio, scrollToMinimapRatio]);
|
||||
|
||||
const scrollToNode = useCallback((node: NodeInfo) => {
|
||||
const refs = messageRefs.current;
|
||||
const el = refs?.[node.index];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, [messageRefs]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const viewportBoxTop = scrollRatio * (1 - viewportRatio) * 100;
|
||||
const viewportBoxHeight = viewportRatio * 100;
|
||||
|
||||
// Find the node closest to the current mouse position
|
||||
const nearestIndex = mouseYRatio !== null && nodes.length > 0
|
||||
? nodes.reduce((best, node) => {
|
||||
const nodeMid = node.topRatio + node.heightRatio / 2;
|
||||
const bestMid = nodes[best].topRatio + nodes[best].heightRatio / 2;
|
||||
return Math.abs(nodeMid - mouseYRatio) < Math.abs(bestMid - mouseYRatio) ? node.index : best;
|
||||
}, 0)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseEnter={() => setMinimapHovered(true)}
|
||||
onMouseLeave={() => { setMinimapHovered(false); setMouseYRatio(null); }}
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMouseYRatio((e.clientY - rect.top) / rect.height);
|
||||
}}
|
||||
style={{
|
||||
width: MINIMAP_WIDTH,
|
||||
flexShrink: 0,
|
||||
position: "relative",
|
||||
cursor: "ns-resize",
|
||||
userSelect: "none",
|
||||
borderLeft: "1px solid var(--border)",
|
||||
background: "var(--bg-panel)",
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{/* Viewport indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: `${viewportBoxTop}%`,
|
||||
height: `${viewportBoxHeight}%`,
|
||||
background: "rgba(100,100,100,0.1)",
|
||||
borderTop: "1px solid rgba(100,100,100,0.2)",
|
||||
borderBottom: "1px solid rgba(100,100,100,0.2)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Message nodes */}
|
||||
{nodes.map((node) => {
|
||||
const color = getNodeColor(node.msg);
|
||||
const isNearest = minimapHovered && nearestIndex === node.index;
|
||||
const preview = getMessagePreview(node.msg);
|
||||
const isUser = node.msg.role === "user";
|
||||
const dotTop = node.topRatio * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.index}
|
||||
data-minimap-node="1"
|
||||
onClick={() => scrollToNode(node)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${dotTop}%`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{/* Dot */}
|
||||
<div
|
||||
style={{
|
||||
width: isUser ? 8 : 6,
|
||||
height: isUser ? 8 : 6,
|
||||
borderRadius: isUser ? 2 : "50%",
|
||||
background: color.bg,
|
||||
border: `1.5px solid ${color.border}`,
|
||||
flexShrink: 0,
|
||||
transition: "transform 0.1s",
|
||||
transform: isNearest ? "scale(1.6)" : "scale(1)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tooltip — shown for all nodes when minimap is hovered */}
|
||||
{minimapHovered && preview && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "100%",
|
||||
marginRight: 6,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
background: "var(--bg)",
|
||||
borderTop: `1px solid ${isNearest ? color.border : "var(--border)"}`,
|
||||
borderRight: `1px solid ${isNearest ? color.border : "var(--border)"}`,
|
||||
borderBottom: `1px solid ${isNearest ? color.border : "var(--border)"}`,
|
||||
borderLeft: `2px solid ${color.border}`,
|
||||
borderRadius: 4,
|
||||
padding: "3px 7px",
|
||||
width: 200,
|
||||
zIndex: 100,
|
||||
pointerEvents: "none",
|
||||
opacity: isNearest ? 1 : 0.45,
|
||||
transition: "opacity 0.1s",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: isNearest ? "var(--text)" : "var(--text-muted)",
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Center line */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 1,
|
||||
background: "var(--border)",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook to create a stable array of refs for messages
|
||||
export function useMessageRefs(count: number): RefObject<(HTMLDivElement | null)[]> {
|
||||
const refs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
refs.current = Array(count).fill(null).map((_, i) => refs.current[i] ?? null);
|
||||
return refs;
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useCallback, useReducer } from "react";
|
||||
import type { SessionInfo, SessionTreeNode, AgentMessage } from "@/lib/types";
|
||||
import { normalizeToolCalls } from "@/lib/normalize";
|
||||
import { MessageView } from "./MessageView";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { BranchNavigator } from "./BranchNavigator";
|
||||
import { ToolPanel, type ToolEntry } from "./ToolPanel";
|
||||
import { ChatMinimap, useMessageRefs } from "./ChatMinimap";
|
||||
|
||||
interface SessionData {
|
||||
sessionId: string;
|
||||
filePath: string;
|
||||
tree: SessionTreeNode[];
|
||||
leafId: string | null;
|
||||
context: {
|
||||
messages: AgentMessage[];
|
||||
entryIds: string[];
|
||||
thinkingLevel: string;
|
||||
model: { provider: string; modelId: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface AgentEvent {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface StreamingState {
|
||||
isStreaming: boolean;
|
||||
streamingMessage: Partial<AgentMessage> | null;
|
||||
}
|
||||
|
||||
type StreamAction =
|
||||
| { type: "start" }
|
||||
| { type: "update"; message: Partial<AgentMessage> }
|
||||
| { type: "end" }
|
||||
| { type: "reset" };
|
||||
|
||||
function streamReducer(state: StreamingState, action: StreamAction): StreamingState {
|
||||
switch (action.type) {
|
||||
case "start":
|
||||
return { isStreaming: true, streamingMessage: null };
|
||||
case "update":
|
||||
return { isStreaming: true, streamingMessage: action.message };
|
||||
case "end":
|
||||
case "reset":
|
||||
return { isStreaming: false, streamingMessage: null };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
session: SessionInfo | null;
|
||||
newSessionCwd: string | null;
|
||||
onAgentEnd?: () => void;
|
||||
onSessionCreated?: (session: SessionInfo) => void;
|
||||
onSessionForked?: (newSessionId: string) => void;
|
||||
}
|
||||
|
||||
export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreated, onSessionForked }: Props) {
|
||||
const isNew = session === null && newSessionCwd !== null;
|
||||
|
||||
const [data, setData] = useState<SessionData | null>(null);
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeLeafId, setActiveLeafId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<AgentMessage[]>([]);
|
||||
const [entryIds, setEntryIds] = useState<string[]>([]);
|
||||
const [streamState, dispatch] = useReducer(streamReducer, {
|
||||
isStreaming: false,
|
||||
streamingMessage: null,
|
||||
});
|
||||
const [agentRunning, setAgentRunning] = useState(false);
|
||||
const [modelNames, setModelNames] = useState<Record<string, string>>({});
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const visibleMessages = messages.filter((m) => m.role === "user" || m.role === "assistant");
|
||||
const messageRefs = useMessageRefs(visibleMessages.length);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
// Always holds the current real session id once known
|
||||
const sessionIdRef = useRef<string | null>(session?.id ?? null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const loadSession = useCallback(async (sid: string, showLoading = false) => {
|
||||
try {
|
||||
if (showLoading) setLoading(true);
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sid)}`);
|
||||
if (res.status === 404) {
|
||||
if (showLoading) {
|
||||
setData(null);
|
||||
setActiveLeafId(null);
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const d = await res.json() as SessionData;
|
||||
setData(d);
|
||||
setActiveLeafId(d.leafId);
|
||||
setMessages(d.context.messages);
|
||||
setEntryIds(d.context.entryIds ?? []);
|
||||
setCurrentModelOverride(null);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadContext = useCallback(async (sid: string, leafId: string | null) => {
|
||||
try {
|
||||
const url = leafId
|
||||
? `/api/sessions/${encodeURIComponent(sid)}/context?leafId=${encodeURIComponent(leafId)}`
|
||||
: `/api/sessions/${encodeURIComponent(sid)}/context`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const d = await res.json() as { context: { messages: AgentMessage[] } };
|
||||
setMessages(d.context.messages);
|
||||
} catch (e) {
|
||||
console.error("Failed to load context:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connectEvents = useCallback((sid: string) => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
const es = new EventSource(`/api/agent/${encodeURIComponent(sid)}/events`);
|
||||
eventSourceRef.current = es;
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const event = JSON.parse(e.data) as AgentEvent;
|
||||
handleAgentEvent(event);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
es.onerror = () => { /* silent */ };
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleAgentEvent = useCallback((event: AgentEvent) => {
|
||||
switch (event.type) {
|
||||
case "agent_start":
|
||||
setAgentRunning(true);
|
||||
dispatch({ type: "start" });
|
||||
break;
|
||||
case "agent_end":
|
||||
setAgentRunning(false);
|
||||
dispatch({ type: "end" });
|
||||
// Reload from file to get toolResult pairing and accurate final state
|
||||
if (sessionIdRef.current) loadSession(sessionIdRef.current);
|
||||
onAgentEnd?.();
|
||||
break;
|
||||
case "message_update": {
|
||||
const msg = event.message as Partial<AgentMessage> | undefined;
|
||||
if (msg) {
|
||||
dispatch({ type: "update", message: normalizeToolCalls(msg as AgentMessage) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "message_end": {
|
||||
const completed = event.message as AgentMessage | undefined;
|
||||
if (completed) {
|
||||
setMessages((prev) => [...prev, normalizeToolCalls(completed)]);
|
||||
}
|
||||
dispatch({ type: "reset" });
|
||||
break;
|
||||
}
|
||||
case "auto_compaction_start":
|
||||
setIsCompacting(true);
|
||||
setCompactError(null);
|
||||
break;
|
||||
case "auto_compaction_end":
|
||||
setIsCompacting(false);
|
||||
if (event.errorMessage) {
|
||||
setCompactError(event.errorMessage as string);
|
||||
} else if (!event.aborted) {
|
||||
// Reload session to display the compaction summary
|
||||
if (sessionIdRef.current) loadSession(sessionIdRef.current);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [loadSession, onAgentEnd]);
|
||||
|
||||
// On mount: load existing session, or show empty chat for new session
|
||||
useEffect(() => {
|
||||
fetch("/api/models").then((r) => r.json()).then((d: { models: Record<string, string> }) => setModelNames(d.models)).catch(() => {});
|
||||
|
||||
if (session) {
|
||||
sessionIdRef.current = session.id;
|
||||
loadSession(session.id, true);
|
||||
// If the agent is already running (e.g. page refresh mid-stream), reconnect SSE
|
||||
// Also sync agent state (thinking level, auto flags)
|
||||
fetch(`/api/agent/${encodeURIComponent(session.id)}`)
|
||||
.then((r) => r.json())
|
||||
.then((d: { running?: boolean; state?: { isStreaming?: boolean; thinkingLevel?: string; isCompacting?: boolean } }) => {
|
||||
if (d.running && d.state?.isStreaming) {
|
||||
setAgentRunning(true);
|
||||
connectEvents(session.id);
|
||||
}
|
||||
if (d.state) {
|
||||
if (d.state.thinkingLevel) setThinkingLevel(d.state.thinkingLevel);
|
||||
if (d.state.isCompacting !== undefined) setIsCompacting(d.state.isCompacting);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
// isNew: nothing to load, just show empty chat
|
||||
return () => {
|
||||
eventSourceRef.current?.close();
|
||||
eventSourceRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) setTimeout(scrollToBottom, 50);
|
||||
}, [messages.length, scrollToBottom]);
|
||||
|
||||
const handleLeafChange = useCallback(async (leafId: string | null) => {
|
||||
setActiveLeafId(leafId);
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid) return;
|
||||
// Update display context
|
||||
await loadContext(sid, leafId);
|
||||
// Tell the agent to navigate to this node so next prompt branches from here
|
||||
if (leafId) {
|
||||
fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "navigate_tree", targetId: leafId }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [loadContext]);
|
||||
|
||||
const handleSend = useCallback(async (message: string) => {
|
||||
if (!message.trim() || agentRunning) return;
|
||||
|
||||
const userMsg: AgentMessage = {
|
||||
role: "user",
|
||||
content: message,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setAgentRunning(true);
|
||||
dispatch({ type: "start" });
|
||||
scrollToBottom();
|
||||
|
||||
try {
|
||||
if (isNew && newSessionCwd) {
|
||||
// Brand-new session: single POST that spawns pi and sends the message
|
||||
const res = await fetch("/api/agent/new", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cwd: newSessionCwd, type: "prompt", message }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json() as { sessionId: string };
|
||||
const realId = data.sessionId;
|
||||
|
||||
// Now we have the real id — connect SSE and update parent
|
||||
sessionIdRef.current = realId;
|
||||
connectEvents(realId);
|
||||
onSessionCreated?.({
|
||||
id: realId,
|
||||
path: "",
|
||||
cwd: newSessionCwd,
|
||||
name: undefined,
|
||||
created: new Date().toISOString(),
|
||||
modified: new Date().toISOString(),
|
||||
messageCount: 1,
|
||||
firstMessage: message,
|
||||
});
|
||||
} else if (session) {
|
||||
// Existing session: connect SSE then POST
|
||||
connectEvents(session.id);
|
||||
const res = await fetch(`/api/agent/${encodeURIComponent(session.id)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "prompt", message }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to send message:", e);
|
||||
setAgentRunning(false);
|
||||
dispatch({ type: "end" });
|
||||
}
|
||||
}, [isNew, newSessionCwd, session, agentRunning, scrollToBottom, connectEvents, onSessionCreated]);
|
||||
|
||||
const handleAbort = useCallback(async () => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid) return;
|
||||
try {
|
||||
await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "abort" }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to abort:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [forkingEntryId, setForkingEntryId] = useState<string | null>(null);
|
||||
|
||||
const handleFork = useCallback(async (entryId: string) => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid) return;
|
||||
setForkingEntryId(entryId);
|
||||
try {
|
||||
const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "fork", entryId }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json() as { success?: boolean; data?: { cancelled?: boolean; newSessionId?: string } };
|
||||
const { cancelled, newSessionId } = data.data ?? {};
|
||||
if (!cancelled && newSessionId) {
|
||||
onSessionForked?.(newSessionId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fork failed:", e);
|
||||
} finally {
|
||||
setForkingEntryId(null);
|
||||
}
|
||||
}, [onSessionForked]);
|
||||
|
||||
const handleNavigate = useCallback(async (entryId: string) => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid) return;
|
||||
// Update leaf pointer in agent
|
||||
fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "navigate_tree", targetId: entryId }),
|
||||
}).catch(() => {});
|
||||
// Update display to show history up to this node
|
||||
setActiveLeafId(entryId);
|
||||
await loadContext(sid, entryId);
|
||||
}, [loadContext]);
|
||||
|
||||
const [currentModelOverride, setCurrentModelOverride] = useState<{ provider: string; modelId: string } | null>(null);
|
||||
const currentModel = currentModelOverride ?? data?.context.model ?? null;
|
||||
|
||||
const [thinkingLevel, setThinkingLevel] = useState<string>("off");
|
||||
const [isCompacting, setIsCompacting] = useState(false);
|
||||
const [compactError, setCompactError] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!compactError) return;
|
||||
const t = setTimeout(() => setCompactError(null), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}, [compactError]);
|
||||
const [tools, setTools] = useState<ToolEntry[]>([]);
|
||||
const [toolPanelOpen, setToolPanelOpen] = useState(false);
|
||||
|
||||
const handleModelChange = useCallback(async (provider: string, modelId: string) => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid) return;
|
||||
try {
|
||||
await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "set_model", provider, modelId }),
|
||||
});
|
||||
setCurrentModelOverride({ provider, modelId });
|
||||
} catch (e) {
|
||||
console.error("Failed to set model:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleThinkingLevelChange = useCallback(async (level: string) => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid) return;
|
||||
setThinkingLevel(level);
|
||||
try {
|
||||
await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "set_thinking_level", level }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to set thinking level:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCompact = useCallback(async () => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid || isCompacting) return;
|
||||
setIsCompacting(true);
|
||||
setCompactError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "compact" }),
|
||||
});
|
||||
const data = await res.json() as { success?: boolean; error?: string };
|
||||
if (!res.ok || data.error) {
|
||||
setCompactError(data.error ?? `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
await loadSession(sid, true);
|
||||
} catch (e) {
|
||||
setCompactError(String(e));
|
||||
} finally {
|
||||
setIsCompacting(false);
|
||||
}
|
||||
}, [isCompacting, loadSession]);
|
||||
|
||||
const handleSteer = useCallback(async (message: string) => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid) return;
|
||||
try {
|
||||
await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "steer", message }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to steer:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFollowUp = useCallback(async (message: string) => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid) return;
|
||||
try {
|
||||
await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "follow_up", message }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to follow up:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAbortCompaction = useCallback(async () => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid) return;
|
||||
try {
|
||||
await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "abort_compaction" }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to abort compaction:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTools = useCallback(async (sid: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "get_tools" }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json() as { success?: boolean; data?: ToolEntry[] };
|
||||
if (data.data) setTools(data.data);
|
||||
} catch (e) {
|
||||
console.error("Failed to load tools:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToolsClick = useCallback(async () => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid) return;
|
||||
// Load tools fresh each time panel opens
|
||||
await loadTools(sid);
|
||||
setToolPanelOpen(true);
|
||||
}, [loadTools]);
|
||||
|
||||
const handleToolToggle = useCallback(async (name: string, active: boolean) => {
|
||||
// Optimistic update
|
||||
setTools(prev => prev.map(t => t.name === name ? { ...t, active } : t));
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid) return;
|
||||
// Compute new active list based on updated tools
|
||||
const newActiveNames = tools
|
||||
.map(t => t.name === name ? { ...t, active } : t)
|
||||
.filter(t => t.active)
|
||||
.map(t => t.name);
|
||||
try {
|
||||
await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "set_tools", toolNames: newActiveNames }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to set tools:", e);
|
||||
// Revert on error
|
||||
setTools(prev => prev.map(t => t.name === name ? { ...t, active: !active } : t));
|
||||
}
|
||||
}, [tools]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-muted)" }}>
|
||||
Loading session...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#f87171" }}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
|
||||
{data && data.tree.length > 0 && (
|
||||
<BranchNavigator
|
||||
tree={data.tree}
|
||||
activeLeafId={activeLeafId}
|
||||
onLeafChange={handleLeafChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
|
||||
<div ref={scrollContainerRef} style={{ flex: 1, overflowY: "auto", padding: "16px 0" }}>
|
||||
<div style={{ maxWidth: 820, margin: "0 auto", padding: "0 16px" }}>
|
||||
{(() => {
|
||||
// Build toolCallId -> ToolResultMessage map for inline pairing
|
||||
const toolResultsMap = new Map<string, import("@/lib/types").ToolResultMessage>();
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "toolResult") {
|
||||
toolResultsMap.set((msg as import("@/lib/types").ToolResultMessage).toolCallId, msg as import("@/lib/types").ToolResultMessage);
|
||||
}
|
||||
}
|
||||
let refIdx = 0;
|
||||
return messages.map((msg, idx) => {
|
||||
// For user messages, find the previous assistant's entryId for "Continue" button
|
||||
const prevAssistantEntryId =
|
||||
msg.role === "user" && idx > 0 && messages[idx - 1].role === "assistant"
|
||||
? entryIds[idx - 1]
|
||||
: undefined;
|
||||
const isVisible = msg.role === "user" || msg.role === "assistant";
|
||||
const currentRefIdx = isVisible ? refIdx++ : -1;
|
||||
const view = (
|
||||
<MessageView
|
||||
key={idx}
|
||||
message={msg}
|
||||
toolResults={toolResultsMap}
|
||||
modelNames={modelNames}
|
||||
entryId={entryIds[idx]}
|
||||
onFork={agentRunning || isNew || (idx === 0 && msg.role === "user") ? undefined : handleFork}
|
||||
forking={forkingEntryId === entryIds[idx]}
|
||||
onNavigate={agentRunning ? undefined : handleNavigate}
|
||||
prevAssistantEntryId={agentRunning ? undefined : prevAssistantEntryId}
|
||||
/>
|
||||
);
|
||||
if (!isVisible) return view;
|
||||
return (
|
||||
<div key={idx} ref={(el) => { messageRefs.current[currentRefIdx] = el; }}>
|
||||
{view}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{streamState.isStreaming && streamState.streamingMessage && (
|
||||
<MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} />
|
||||
)}
|
||||
|
||||
{streamState.isStreaming && !streamState.streamingMessage && (
|
||||
<div style={{ padding: "8px 0", color: "var(--text-muted)", fontSize: 13 }}>
|
||||
<span style={{ animation: "pulse 1.5s infinite" }}>Thinking...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
<ChatMinimap
|
||||
messages={messages}
|
||||
streamingMessage={streamState.streamingMessage}
|
||||
scrollContainer={scrollContainerRef}
|
||||
messageRefs={messageRefs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "relative" }}>
|
||||
{toolPanelOpen && tools.length > 0 && (
|
||||
<div style={{ position: "absolute", bottom: "100%", right: 16, zIndex: 200 }}>
|
||||
<ToolPanel
|
||||
tools={tools}
|
||||
onToggle={handleToolToggle}
|
||||
onClose={() => setToolPanelOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onAbort={handleAbort}
|
||||
onSteer={agentRunning ? handleSteer : undefined}
|
||||
onFollowUp={agentRunning ? handleFollowUp : undefined}
|
||||
isStreaming={agentRunning}
|
||||
model={currentModel}
|
||||
modelNames={modelNames}
|
||||
onModelChange={handleModelChange}
|
||||
thinkingLevel={thinkingLevel}
|
||||
onThinkingLevelChange={session || isNew ? handleThinkingLevelChange : undefined}
|
||||
onCompact={session || isNew ? handleCompact : undefined}
|
||||
onAbortCompaction={handleAbortCompaction}
|
||||
isCompacting={isCompacting}
|
||||
compactError={compactError}
|
||||
onToolsClick={session || isNew ? handleToolsClick : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { getFileIcon, FolderIcon } from "./FileIcons";
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
size: number;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
interface FileNode {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
isDir: boolean;
|
||||
size: number;
|
||||
children?: FileNode[];
|
||||
loaded?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cwd: string;
|
||||
onOpenFile: (filePath: string, fileName: string) => void;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
depth,
|
||||
onOpenFile,
|
||||
}: {
|
||||
node: FileNode;
|
||||
depth: number;
|
||||
onOpenFile: (filePath: string, fileName: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [children, setChildren] = useState<FileNode[]>(node.children ?? []);
|
||||
const [loaded, setLoaded] = useState(node.loaded ?? false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const loadChildren = useCallback(async () => {
|
||||
if (loaded) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const encoded = node.fullPath.split("/").filter(Boolean).join("/");
|
||||
const res = await fetch(`/api/files/${encoded}?type=list`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json() as { entries: FileEntry[] };
|
||||
setChildren(
|
||||
data.entries.map((e) => ({
|
||||
name: e.name,
|
||||
fullPath: node.fullPath.replace(/\/$/, "") + "/" + e.name,
|
||||
isDir: e.isDir,
|
||||
size: e.size,
|
||||
children: e.isDir ? [] : undefined,
|
||||
loaded: !e.isDir,
|
||||
}))
|
||||
);
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loaded, node.fullPath]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (node.isDir) {
|
||||
if (!loaded && !open) loadChildren();
|
||||
setOpen((v) => !v);
|
||||
} else {
|
||||
onOpenFile(node.fullPath, node.name);
|
||||
}
|
||||
}, [node.isDir, node.fullPath, node.name, loaded, open, loadChildren, onOpenFile]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
paddingLeft: 8 + depth * 14,
|
||||
paddingRight: 8,
|
||||
height: 24,
|
||||
cursor: "pointer",
|
||||
background: hovered ? "var(--bg-hover)" : "transparent",
|
||||
borderRadius: 4,
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{node.isDir && (
|
||||
<svg
|
||||
width="10" height="10" viewBox="0 0 10 10" fill="none"
|
||||
stroke="var(--text-dim)" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ flexShrink: 0, transform: open ? "rotate(90deg)" : "none", transition: "transform 0.1s" }}
|
||||
>
|
||||
<polyline points="3 2 7 5 3 8" />
|
||||
</svg>
|
||||
)}
|
||||
{!node.isDir && <span style={{ width: 10, flexShrink: 0 }} />}
|
||||
<span style={{ flexShrink: 0, display: "flex", alignItems: "center" }}>
|
||||
{node.isDir ? <FolderIcon size={14} open={open} /> : getFileIcon(node.name, 14)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
}}
|
||||
title={node.fullPath}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
{loading && (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{node.isDir && open && (
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<TreeNode key={child.fullPath} node={child} depth={depth + 1} onOpenFile={onOpenFile} />
|
||||
))}
|
||||
{children.length === 0 && loaded && (
|
||||
<div style={{ paddingLeft: 8 + (depth + 1) * 14, fontSize: 11, color: "var(--text-dim)", height: 22, display: "flex", alignItems: "center" }}>
|
||||
empty
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileExplorer({ cwd, onOpenFile, refreshKey }: Props) {
|
||||
const [roots, setRoots] = useState<FileNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [treeKey, setTreeKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setTreeKey((k) => k + 1);
|
||||
}, [refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const encoded = cwd.split("/").filter(Boolean).join("/");
|
||||
fetch(`/api/files/${encoded}?type=list`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { entries?: FileEntry[]; error?: string }) => {
|
||||
if (data.error) {
|
||||
setError(data.error);
|
||||
return;
|
||||
}
|
||||
setRoots(
|
||||
(data.entries ?? []).map((e) => ({
|
||||
name: e.name,
|
||||
fullPath: cwd.replace(/\/$/, "") + "/" + e.name,
|
||||
isDir: e.isDir,
|
||||
size: e.size,
|
||||
children: e.isDir ? [] : undefined,
|
||||
loaded: !e.isDir,
|
||||
}))
|
||||
);
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [cwd, treeKey]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: "8px 12px", fontSize: 11, color: "var(--text-dim)" }}>
|
||||
Loading files...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: "8px 12px", fontSize: 11, color: "#f87171" }}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "2px 4px" }}>
|
||||
{roots.map((node) => (
|
||||
<TreeNode key={`${treeKey}:${node.fullPath}`} node={node} depth={0} onOpenFile={onOpenFile} />
|
||||
))}
|
||||
{roots.length === 0 && (
|
||||
<div style={{ padding: "8px 12px", fontSize: 11, color: "var(--text-dim)" }}>
|
||||
No files found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Flat monochrome file & folder icons — all use currentColor / var(--text-dim)
|
||||
|
||||
interface IconProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const DIM = "var(--text-dim)";
|
||||
|
||||
// ── Folder ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FolderIcon({ size = 14, open = false }: IconProps & { open?: boolean }) {
|
||||
if (open) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<path d="M1 4.5A1 1 0 0 1 2 3.5H5.5L7 5h7.5v1H1V4.5Z" fill={DIM} />
|
||||
<path d="M1 6h14.5L14 13H2L1 6Z" stroke={DIM} strokeWidth="1" fill={DIM} fillOpacity="0.12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<path d="M1 4.5A1 1 0 0 1 2 3.5H5.5L7 5H14a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4.5Z"
|
||||
stroke={DIM} strokeWidth="1" fill={DIM} fillOpacity="0.1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Generic file (fallback) ────────────────────────────────────────────────
|
||||
|
||||
export function GenericFileIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<path d="M3 2h7l3 3v9H3V2Z" stroke={DIM} strokeWidth="1" fill={DIM} fillOpacity="0.08" />
|
||||
<path d="M10 2v3h3" stroke={DIM} strokeWidth="1" fill="none" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── File with label text (used for most types) ────────────────────────────
|
||||
// Renders the file outline + a short text badge
|
||||
|
||||
function LabelFileIcon({ label, size = 14 }: { label: string; size?: number }) {
|
||||
const s = size / 14; // scale factor
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<text
|
||||
x="7" y="9.5"
|
||||
textAnchor="middle"
|
||||
fontSize={3.4 * s}
|
||||
fontFamily="var(--font-mono), monospace"
|
||||
fontWeight="600"
|
||||
fill={DIM}
|
||||
letterSpacing="0"
|
||||
>{label}</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Specific icons ────────────────────────────────────────────────────────
|
||||
|
||||
export function TypeScriptIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="TS" size={size} />;
|
||||
}
|
||||
export function TypeScriptReactIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="TSX" size={size} />;
|
||||
}
|
||||
export function JavaScriptIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="JS" size={size} />;
|
||||
}
|
||||
export function JavaScriptReactIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="JSX" size={size} />;
|
||||
}
|
||||
export function PythonIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="PY" size={size} />;
|
||||
}
|
||||
export function JsonIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="{}" size={size} />;
|
||||
}
|
||||
export function CssIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="CSS" size={size} />;
|
||||
}
|
||||
export function ScssIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="SC" size={size} />;
|
||||
}
|
||||
export function HtmlIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="HTM" size={size} />;
|
||||
}
|
||||
export function MarkdownIcon({ size = 14 }: IconProps) {
|
||||
// file outline + M↓ symbol
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
{/* M */}
|
||||
<path d="M3.5 9.5V7l1.5 1.5L6.5 7v2.5" stroke={DIM} strokeWidth="0.9" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
{/* down arrow */}
|
||||
<path d="M8 7v2.5M7 9l1 1.5 1-1.5" stroke={DIM} strokeWidth="0.9" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function YamlIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="YML" size={size} />;
|
||||
}
|
||||
export function TomlIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="TOM" size={size} />;
|
||||
}
|
||||
export function ShellIcon({ size = 14 }: IconProps) {
|
||||
// file outline + > prompt
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<path d="M4 7.5l2 1.5-2 1.5" stroke={DIM} strokeWidth="0.95" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<path d="M7.5 10.5h2.5" stroke={DIM} strokeWidth="0.95" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function RustIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="RS" size={size} />;
|
||||
}
|
||||
export function GoIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="GO" size={size} />;
|
||||
}
|
||||
export function SqlIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="SQL" size={size} />;
|
||||
}
|
||||
export function GraphqlIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="GQL" size={size} />;
|
||||
}
|
||||
export function TerraformIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="TF" size={size} />;
|
||||
}
|
||||
export function DockerfileIcon({ size = 14 }: IconProps) {
|
||||
// file outline + container stack
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<rect x="3.5" y="6.5" width="2" height="1.5" rx="0.3" stroke={DIM} strokeWidth="0.8" />
|
||||
<rect x="6" y="6.5" width="2" height="1.5" rx="0.3" stroke={DIM} strokeWidth="0.8" />
|
||||
<rect x="3.5" y="8.5" width="2" height="1.5" rx="0.3" stroke={DIM} strokeWidth="0.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function EnvIcon({ size = 14 }: IconProps) {
|
||||
// file outline + key symbol
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<circle cx="5.5" cy="8.5" r="1.5" stroke={DIM} strokeWidth="0.9" />
|
||||
<path d="M7 8.5h2.5M8.5 8.5v1.5" stroke={DIM} strokeWidth="0.9" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function GitIcon({ size = 14 }: IconProps) {
|
||||
// file outline + git branch lines
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<circle cx="5" cy="6.5" r="1" stroke={DIM} strokeWidth="0.85" />
|
||||
<circle cx="9" cy="6.5" r="1" stroke={DIM} strokeWidth="0.85" />
|
||||
<circle cx="5" cy="10" r="1" stroke={DIM} strokeWidth="0.85" />
|
||||
<path d="M5 7.5V9" stroke={DIM} strokeWidth="0.85" strokeLinecap="round" />
|
||||
<path d="M9 7.5v.5a2 2 0 0 1-2 2H6" stroke={DIM} strokeWidth="0.85" strokeLinecap="round" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function LockFileIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<rect x="4.5" y="8.5" width="5" height="3" rx="0.6" stroke={DIM} strokeWidth="0.9" />
|
||||
<path d="M5.5 8.5V7.5a1.5 1.5 0 0 1 3 0v1" stroke={DIM} strokeWidth="0.9" strokeLinecap="round" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function ConfigIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<circle cx="7" cy="8.5" r="1.3" stroke={DIM} strokeWidth="0.9" />
|
||||
<path d="M7 6.5v.7M7 10.3v.7M5 8.5h.7M8.3 8.5H9M5.5 6.9l.5.5M8.5 9.6l-.5-.5M5.5 10.1l.5-.5M8.5 7.4l-.5.5"
|
||||
stroke={DIM} strokeWidth="0.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main resolver ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getFileIcon(name: string, size = 14): React.ReactNode {
|
||||
const lower = name.toLowerCase();
|
||||
const ext = lower.split(".").pop() ?? "";
|
||||
|
||||
if (lower === "dockerfile" || lower.startsWith("dockerfile.")) return <DockerfileIcon size={size} />;
|
||||
if (lower === ".env" || lower.startsWith(".env.")) return <EnvIcon size={size} />;
|
||||
if (lower === ".gitignore" || lower === ".gitattributes" || lower === ".gitmodules") return <GitIcon size={size} />;
|
||||
if (lower === "package-lock.json" || lower === "yarn.lock" || lower === "bun.lock" || lower === "pnpm-lock.yaml" || lower === "cargo.lock") return <LockFileIcon size={size} />;
|
||||
if (lower.endsWith(".config.ts") || lower.endsWith(".config.js") || lower.endsWith(".config.mjs") || lower.endsWith(".config.cjs")) return <ConfigIcon size={size} />;
|
||||
if ([".eslintrc", ".eslintrc.js", ".eslintrc.json", ".eslintrc.yml", "eslint.config.mjs", "eslint.config.js"].includes(lower)) return <ConfigIcon size={size} />;
|
||||
|
||||
switch (ext) {
|
||||
case "ts": return <TypeScriptIcon size={size} />;
|
||||
case "tsx": return <TypeScriptReactIcon size={size} />;
|
||||
case "js":
|
||||
case "mjs":
|
||||
case "cjs": return <JavaScriptIcon size={size} />;
|
||||
case "jsx": return <JavaScriptReactIcon size={size} />;
|
||||
case "py": return <PythonIcon size={size} />;
|
||||
case "json":
|
||||
case "jsonl": return <JsonIcon size={size} />;
|
||||
case "css":
|
||||
case "less": return <CssIcon size={size} />;
|
||||
case "scss": return <ScssIcon size={size} />;
|
||||
case "html":
|
||||
case "htm": return <HtmlIcon size={size} />;
|
||||
case "md":
|
||||
case "mdx": return <MarkdownIcon size={size} />;
|
||||
case "yaml":
|
||||
case "yml": return <YamlIcon size={size} />;
|
||||
case "toml": return <TomlIcon size={size} />;
|
||||
case "sh":
|
||||
case "bash":
|
||||
case "zsh":
|
||||
case "fish": return <ShellIcon size={size} />;
|
||||
case "rs": return <RustIcon size={size} />;
|
||||
case "go": return <GoIcon size={size} />;
|
||||
case "sql": return <SqlIcon size={size} />;
|
||||
case "graphql":
|
||||
case "gql": return <GraphqlIcon size={size} />;
|
||||
case "tf":
|
||||
case "hcl": return <TerraformIcon size={size} />;
|
||||
case "lock": return <LockFileIcon size={size} />;
|
||||
default: return <GenericFileIcon size={size} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
interface FileData {
|
||||
content: string;
|
||||
language: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function FileViewer({ filePath }: Props) {
|
||||
const [data, setData] = useState<FileData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setData(null);
|
||||
setPreviewMode(false);
|
||||
const encoded = filePath.split("/").filter(Boolean).join("/");
|
||||
fetch(`/api/files/${encoded}?type=read`)
|
||||
.then((r) => r.json())
|
||||
.then((d: FileData & { error?: string }) => {
|
||||
if (d.error) { setError(d.error); return; }
|
||||
setData(d);
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filePath]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-muted)", fontSize: 13 }}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#f87171", fontSize: 13 }}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const isHtml = data.language === "html";
|
||||
const lines = data.content.split("\n");
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
|
||||
{/* Status bar */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "4px 16px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
fontSize: 11,
|
||||
color: "var(--text-dim)",
|
||||
background: "var(--bg-panel)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: "var(--font-mono)" }}>{filePath}</span>
|
||||
<span style={{ marginLeft: "auto" }}>{data.language}</span>
|
||||
{!previewMode && <span>{lines.length} lines</span>}
|
||||
<span>{formatSize(data.size)}</span>
|
||||
{isHtml && (
|
||||
<div style={{ display: "flex", borderRadius: 5, overflow: "hidden", border: "1px solid var(--border)" }}>
|
||||
<button
|
||||
onClick={() => setPreviewMode(false)}
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
fontSize: 11,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: !previewMode ? "var(--bg-selected)" : "var(--bg-hover)",
|
||||
color: !previewMode ? "var(--text)" : "var(--text-muted)",
|
||||
fontWeight: !previewMode ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
Source
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewMode(true)}
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
fontSize: 11,
|
||||
border: "none",
|
||||
borderLeft: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
background: previewMode ? "var(--bg-selected)" : "var(--bg-hover)",
|
||||
color: previewMode ? "var(--text)" : "var(--text-muted)",
|
||||
fontWeight: previewMode ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
{isHtml && previewMode ? (
|
||||
<iframe
|
||||
srcDoc={data.content}
|
||||
sandbox="allow-scripts"
|
||||
style={{ flex: 1, border: "none", background: "#fff" }}
|
||||
title="HTML preview"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1, overflow: "auto", background: "var(--bg)" }}>
|
||||
<table
|
||||
style={{
|
||||
borderCollapse: "collapse",
|
||||
width: "100%",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<tbody>
|
||||
{lines.map((line, idx) => (
|
||||
<tr key={idx} style={{ verticalAlign: "top" }}>
|
||||
<td
|
||||
style={{
|
||||
padding: "0 12px 0 16px",
|
||||
textAlign: "right",
|
||||
color: "var(--text-dim)",
|
||||
userSelect: "none",
|
||||
minWidth: 48,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
borderRight: "1px solid var(--border)",
|
||||
background: "var(--bg-panel)",
|
||||
}}
|
||||
>
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "0 16px",
|
||||
whiteSpace: "pre",
|
||||
color: "var(--text)",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{line || "\u00a0"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type {
|
||||
AgentMessage,
|
||||
UserMessage,
|
||||
AssistantMessage,
|
||||
ToolResultMessage,
|
||||
AssistantContentBlock,
|
||||
TextContent,
|
||||
ToolCallContent,
|
||||
ThinkingContent,
|
||||
} from "@/lib/types";
|
||||
|
||||
interface Props {
|
||||
message: AgentMessage;
|
||||
isStreaming?: boolean;
|
||||
toolResults?: Map<string, ToolResultMessage>;
|
||||
modelNames?: Record<string, string>;
|
||||
entryId?: string;
|
||||
onFork?: (entryId: string) => void;
|
||||
forking?: boolean;
|
||||
onNavigate?: (entryId: string) => void;
|
||||
prevAssistantEntryId?: string;
|
||||
}
|
||||
|
||||
function formatTime(ts?: number): string | null {
|
||||
if (!ts) return null;
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
if (isToday) return time;
|
||||
const date = d.toLocaleDateString([], { month: "short", day: "numeric", year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined });
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
|
||||
export function MessageView({ message, isStreaming, toolResults, modelNames, entryId, onFork, forking, onNavigate, prevAssistantEntryId }: Props) {
|
||||
if (message.role === "user") {
|
||||
return <UserMessageView message={message as UserMessage} entryId={entryId} onFork={onFork} forking={forking} onNavigate={onNavigate} prevAssistantEntryId={prevAssistantEntryId} />;
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
return <AssistantMessageView message={message as AssistantMessage} isStreaming={isStreaming} toolResults={toolResults} modelNames={modelNames} />;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
// Rendered inline under its toolCall — skip standalone rendering if paired
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function UserMessageView({ message, entryId, onFork, forking, onNavigate, prevAssistantEntryId }: {
|
||||
message: UserMessage;
|
||||
entryId?: string;
|
||||
onFork?: (entryId: string) => void;
|
||||
forking?: boolean;
|
||||
onNavigate?: (entryId: string) => void;
|
||||
prevAssistantEntryId?: string;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const content =
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content
|
||||
.filter((b): b is TextContent => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("\n");
|
||||
|
||||
const time = formatTime(message.timestamp);
|
||||
const canFork = !!entryId && !!onFork;
|
||||
const canNavigate = !!prevAssistantEntryId && !!onNavigate;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ marginBottom: 16, display: "flex", flexDirection: "column", alignItems: "flex-end" }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, maxWidth: "85%" }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
background: "var(--user-bg)",
|
||||
border: "1px solid rgba(59,130,246,0.2)",
|
||||
borderRadius: 12,
|
||||
padding: "8px 12px",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.6,
|
||||
color: "var(--text)",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Bottom row: action buttons + timestamp */}
|
||||
{(time || canFork || canNavigate) && (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "flex-end",
|
||||
gap: 6, marginTop: 3,
|
||||
}}>
|
||||
{(canFork || canNavigate) && (
|
||||
<div style={{
|
||||
display: "flex", gap: 3,
|
||||
opacity: (hovered || forking) ? 1 : 0,
|
||||
pointerEvents: (hovered || forking) ? "auto" : "none",
|
||||
transition: "opacity 0.12s",
|
||||
}}>
|
||||
{canNavigate && (
|
||||
<button
|
||||
onClick={() => onNavigate!(prevAssistantEntryId!)}
|
||||
title="Edit from here — branches within this session"
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 5,
|
||||
padding: "5px 12px", height: 28,
|
||||
background: "none", border: "none",
|
||||
borderRadius: 6,
|
||||
color: "var(--text-dim)",
|
||||
cursor: "pointer",
|
||||
fontSize: 13, fontWeight: 500,
|
||||
whiteSpace: "nowrap",
|
||||
transition: "color 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--accent)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-dim)"; }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 10 20 15 15 20" />
|
||||
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
||||
</svg>
|
||||
Edit from here
|
||||
</button>
|
||||
)}
|
||||
{canFork && (
|
||||
<button
|
||||
onClick={() => onFork!(entryId!)}
|
||||
disabled={forking}
|
||||
title={forking ? "Creating new session…" : "New session — creates an independent copy from here"}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 5,
|
||||
padding: "5px 12px", height: 28,
|
||||
background: "none", border: "none",
|
||||
borderRadius: 6,
|
||||
color: forking ? "var(--accent)" : "var(--text-dim)",
|
||||
cursor: forking ? "not-allowed" : "pointer",
|
||||
fontSize: 13, fontWeight: 500,
|
||||
whiteSpace: "nowrap",
|
||||
transition: "color 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!forking) e.currentTarget.style.color = "var(--accent)"; }}
|
||||
onMouseLeave={(e) => { if (!forking) e.currentTarget.style.color = "var(--text-dim)"; }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="6" y1="3" x2="6" y2="15" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
{forking ? "Creating…" : "New session"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{time && <span style={{ fontSize: 10, color: "var(--text-dim)" }}>{time}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantMessageView({
|
||||
message,
|
||||
isStreaming,
|
||||
toolResults,
|
||||
modelNames,
|
||||
}: {
|
||||
message: AssistantMessage;
|
||||
isStreaming?: boolean;
|
||||
toolResults?: Map<string, ToolResultMessage>;
|
||||
modelNames?: Record<string, string>;
|
||||
}) {
|
||||
const blocks = message.content ?? [];
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{/* Model label */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-dim)",
|
||||
marginBottom: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{message.provider && (
|
||||
<span>{modelNames?.[message.model] ?? message.model}</span>
|
||||
)}
|
||||
{isStreaming && (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: "var(--accent)",
|
||||
animation: "blink 1s infinite",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{blocks.map((block, i) => (
|
||||
<BlockView key={i} block={block} toolResults={toolResults} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{message.usage && !isStreaming && (
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: "var(--text-dim)" }}>
|
||||
{formatUsage(message.usage)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockView({ block, toolResults }: { block: AssistantContentBlock; toolResults?: Map<string, ToolResultMessage> }) {
|
||||
if (block.type === "text") {
|
||||
return <TextBlock block={block as TextContent} />;
|
||||
}
|
||||
if (block.type === "thinking") {
|
||||
return <ThinkingBlock block={block as ThinkingContent} />;
|
||||
}
|
||||
if (block.type === "toolCall") {
|
||||
const result = toolResults?.get((block as ToolCallContent).toolCallId);
|
||||
return <ToolCallBlock block={block as ToolCallContent} result={result} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function TextBlock({ block }: { block: TextContent }) {
|
||||
return (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const isBlock = className?.includes("language-");
|
||||
const lang = className?.replace("language-", "") ?? "";
|
||||
if (isBlock) {
|
||||
return <CodeBlock code={String(children).replace(/\n$/, "")} lang={lang} />;
|
||||
}
|
||||
return (
|
||||
<code
|
||||
style={{
|
||||
background: "var(--bg-selected)",
|
||||
padding: "1px 4px",
|
||||
borderRadius: 3,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre({ children }) {
|
||||
// Unwrap <pre> wrapper — CodeBlock handles its own container
|
||||
return <>{children}</>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{block.text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingBlock({ block }: { block: ThinkingContent }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
width: "100%",
|
||||
padding: "6px 10px",
|
||||
background: "var(--bg-panel)",
|
||||
border: "none",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span>{expanded ? "▼" : "▶"}</span>
|
||||
<span>Thinking</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
background: "var(--bg-panel)",
|
||||
borderTop: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{block.thinking}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallBlock({ block, result }: { block: ToolCallContent; result?: ToolResultMessage }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const inputStr = JSON.stringify(block.input, null, 2);
|
||||
|
||||
// Result display
|
||||
const resultText = result
|
||||
? result.content.filter((b): b is { type: "text"; text: string } => b.type === "text").map((b) => b.text).join("\n")
|
||||
: null;
|
||||
const resultIsEmpty = resultText === null ? false : (resultText.trim() === "(no output)" || resultText.trim() === "");
|
||||
const resultPreview = resultIsEmpty ? "(no output)" : (resultText ?? "").slice(0, 120).replace(/\n/g, " ");
|
||||
const isError = result?.isError ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 7,
|
||||
overflow: "hidden",
|
||||
fontSize: 12,
|
||||
border: "1px solid rgba(34,197,94,0.25)",
|
||||
background: "rgba(34,197,94,0.04)",
|
||||
}}
|
||||
>
|
||||
{/* ── Tool call header ── */}
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
width: "100%",
|
||||
padding: "6px 10px",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" style={{ flexShrink: 0, color: "#16a34a" }}>
|
||||
<circle cx="6" cy="6" r="5" stroke="currentColor" strokeWidth="1.4" />
|
||||
<polygon points="4.5,3.5 9,6 4.5,8.5" fill="currentColor" />
|
||||
</svg>
|
||||
<span style={{ color: "#16a34a", fontFamily: "var(--font-mono)", fontWeight: 600, fontSize: 11 }}>
|
||||
{block.toolName}
|
||||
</span>
|
||||
<span style={{ color: "var(--text-dim)", fontFamily: "var(--font-mono)", fontSize: 11, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}>
|
||||
{getToolPreview(block)}
|
||||
</span>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--text-dim)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, transform: expanded ? "rotate(180deg)" : "none", transition: "transform 0.15s" }}>
|
||||
<polyline points="2 3.5 5 6.5 8 3.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* ── Expanded: input args ── */}
|
||||
{expanded && (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "8px 10px",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
overflow: "auto",
|
||||
background: "rgba(0,0,0,0.03)",
|
||||
borderTop: "1px solid rgba(34,197,94,0.2)",
|
||||
}}
|
||||
>
|
||||
{inputStr}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* ── Paired result ── */}
|
||||
{result && (
|
||||
<PairedResult
|
||||
text={resultText ?? ""}
|
||||
preview={resultPreview}
|
||||
isEmpty={resultIsEmpty}
|
||||
isError={isError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PairedResult({ text, preview, isEmpty, isError }: { text: string; preview: string; isEmpty: boolean; isError: boolean }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderTop: `1px solid ${isError ? "rgba(248,113,113,0.3)" : "rgba(34,197,94,0.15)"}`,
|
||||
background: isError ? "rgba(248,113,113,0.04)" : "rgba(0,0,0,0.02)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => isEmpty ? undefined : setExpanded((v) => !v)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
width: "100%",
|
||||
padding: "5px 10px",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: isError ? "#f87171" : "var(--text-dim)",
|
||||
cursor: isEmpty ? "default" : "pointer",
|
||||
textAlign: "left",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{/* return arrow */}
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" style={{ flexShrink: 0, color: isError ? "#f87171" : "var(--text-dim)" }}>
|
||||
<polyline points="4 2 2 6 4 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M2 6h7a2 2 0 0 0 0-4H7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
{!expanded && (
|
||||
<span style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1,
|
||||
fontStyle: isEmpty ? "italic" : "normal",
|
||||
opacity: isEmpty ? 0.5 : 1,
|
||||
color: isError ? "#f87171" : "var(--text-dim)",
|
||||
}}>
|
||||
{preview}
|
||||
</span>
|
||||
)}
|
||||
{!isEmpty && !isError && (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--text-dim)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, transform: expanded ? "rotate(180deg)" : "none", transition: "transform 0.15s" }}>
|
||||
<polyline points="2 3.5 5 6.5 8 3.5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "8px 10px",
|
||||
color: isError ? "#f87171" : "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
overflow: "auto",
|
||||
maxHeight: 300,
|
||||
background: "var(--bg)",
|
||||
borderTop: `1px solid ${isError ? "rgba(248,113,113,0.2)" : "rgba(34,197,94,0.15)"}`,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function getToolPreview(block: ToolCallContent): string {
|
||||
const input = block.input;
|
||||
if (!input || typeof input !== "object") return "";
|
||||
const keys = Object.keys(input);
|
||||
if (keys.length === 0) return "";
|
||||
|
||||
// Common tool input patterns
|
||||
if ("command" in input) return String(input.command).slice(0, 60);
|
||||
if ("path" in input) return String(input.path).slice(0, 60);
|
||||
if ("file_path" in input) return String(input.file_path).slice(0, 60);
|
||||
if ("pattern" in input) return String(input.pattern).slice(0, 60);
|
||||
if ("query" in input) return String(input.query).slice(0, 60);
|
||||
|
||||
const first = input[keys[0]];
|
||||
return String(first).slice(0, 60);
|
||||
}
|
||||
|
||||
function formatUsage(usage: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cost: { total: number };
|
||||
}): string {
|
||||
const parts = [];
|
||||
if (usage.input) parts.push(`${usage.input.toLocaleString()} in`);
|
||||
if (usage.output) parts.push(`${usage.output.toLocaleString()} out`);
|
||||
if (usage.cacheRead) parts.push(`${usage.cacheRead.toLocaleString()} cache`);
|
||||
if (usage.cost?.total) parts.push(`$${usage.cost.total.toFixed(4)}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
|
||||
|
||||
function CodeBlock({ code, lang }: { code: string; lang: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
marginTop: 4,
|
||||
marginBottom: 4,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{lang && (
|
||||
<div
|
||||
style={{
|
||||
padding: "3px 10px",
|
||||
background: "var(--bg-panel)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
fontSize: 11,
|
||||
color: "var(--text-dim)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>{lang}</span>
|
||||
<button
|
||||
onClick={copy}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{copied ? "copied" : "copy"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg)",
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.6,
|
||||
overflowX: "auto",
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,939 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import type { SessionInfo } from "@/lib/types";
|
||||
import { FileExplorer } from "./FileExplorer";
|
||||
|
||||
interface Props {
|
||||
selectedSessionId: string | null;
|
||||
onSelectSession: (session: SessionInfo) => void;
|
||||
onNewSession?: (sessionId: string, cwd: string) => void;
|
||||
initialSessionId?: string | null;
|
||||
refreshKey?: number;
|
||||
onSessionDeleted?: (sessionId: string) => void;
|
||||
selectedCwd?: string | null;
|
||||
onOpenFile?: (filePath: string, fileName: string) => void;
|
||||
explorerRefreshKey?: number;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
/** Return the 5 most recently active cwds across all sessions */
|
||||
function getRecentCwds(sessions: SessionInfo[]): string[] {
|
||||
const latestByCwd = new Map<string, string>(); // cwd -> most recent modified
|
||||
for (const s of sessions) {
|
||||
if (!s.cwd) continue;
|
||||
const prev = latestByCwd.get(s.cwd);
|
||||
if (!prev || s.modified > prev) {
|
||||
latestByCwd.set(s.cwd, s.modified);
|
||||
}
|
||||
}
|
||||
return [...latestByCwd.entries()]
|
||||
.sort((a, b) => b[1].localeCompare(a[1]))
|
||||
.slice(0, 5)
|
||||
.map(([cwd]) => cwd);
|
||||
}
|
||||
|
||||
function displayCwd(cwd: string): string {
|
||||
// Show last 2 path segments for readability
|
||||
const parts = cwd.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
if (parts.length <= 2) return cwd;
|
||||
return "…/" + parts.slice(-2).join("/");
|
||||
}
|
||||
|
||||
interface SessionTreeNode {
|
||||
session: SessionInfo;
|
||||
children: SessionTreeNode[];
|
||||
}
|
||||
|
||||
function buildSessionTree(sessions: SessionInfo[]): SessionTreeNode[] {
|
||||
const byId = new Map<string, SessionTreeNode>();
|
||||
for (const s of sessions) {
|
||||
byId.set(s.id, { session: s, children: [] });
|
||||
}
|
||||
|
||||
// Build a map of parentSessionId chains so we can resolve missing ancestors
|
||||
const parentOf = new Map<string, string>();
|
||||
for (const s of sessions) {
|
||||
if (s.parentSessionId) parentOf.set(s.id, s.parentSessionId);
|
||||
}
|
||||
|
||||
// Walk up the parentSessionId chain to find the nearest ancestor that exists in byId
|
||||
function resolveAncestor(id: string): string | null {
|
||||
let cur = parentOf.get(id);
|
||||
const visited = new Set<string>();
|
||||
while (cur) {
|
||||
if (visited.has(cur)) return null; // cycle guard
|
||||
visited.add(cur);
|
||||
if (byId.has(cur)) return cur;
|
||||
cur = parentOf.get(cur);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const roots: SessionTreeNode[] = [];
|
||||
for (const node of byId.values()) {
|
||||
const ancestor = resolveAncestor(node.session.id);
|
||||
if (ancestor) {
|
||||
byId.get(ancestor)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort each level by modified desc
|
||||
const sort = (nodes: SessionTreeNode[]) => {
|
||||
nodes.sort((a, b) => b.session.modified.localeCompare(a.session.modified));
|
||||
nodes.forEach((n) => sort(n.children));
|
||||
};
|
||||
sort(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSession, initialSessionId, refreshKey, onSessionDeleted, selectedCwd: selectedCwdProp, onOpenFile, explorerRefreshKey }: Props) {
|
||||
const [allSessions, setAllSessions] = useState<SessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedCwd, setSelectedCwd] = useState<string | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [customPathOpen, setCustomPathOpen] = useState(false);
|
||||
const [customPathValue, setCustomPathValue] = useState("");
|
||||
const customPathInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [explorerOpen, setExplorerOpen] = useState(true);
|
||||
const [explorerKey, setExplorerKey] = useState(0);
|
||||
|
||||
const loadSessions = useCallback(async (showLoading = false) => {
|
||||
try {
|
||||
if (showLoading) setLoading(true);
|
||||
const res = await fetch("/api/sessions");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json() as { sessions: SessionInfo[] };
|
||||
setAllSessions(data.sessions);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const initialLoadDone = useRef(false);
|
||||
useEffect(() => {
|
||||
const isFirst = !initialLoadDone.current;
|
||||
initialLoadDone.current = true;
|
||||
loadSessions(isFirst);
|
||||
}, [loadSessions, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (explorerRefreshKey !== undefined) setExplorerKey((k) => k + 1);
|
||||
}, [explorerRefreshKey]);
|
||||
|
||||
const restoredRef = useRef(false);
|
||||
|
||||
// Auto-select cwd and restore session from URL on first load
|
||||
useEffect(() => {
|
||||
if (allSessions.length === 0) return;
|
||||
|
||||
if (selectedCwd === null) {
|
||||
// If restoring a session, set cwd to match that session
|
||||
if (initialSessionId && !restoredRef.current) {
|
||||
const target = allSessions.find((s) => s.id === initialSessionId);
|
||||
if (target) {
|
||||
setSelectedCwd(target.cwd);
|
||||
restoredRef.current = true;
|
||||
onSelectSession(target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const cwds = getRecentCwds(allSessions);
|
||||
if (cwds.length > 0) setSelectedCwd(cwds[0]);
|
||||
}
|
||||
}, [allSessions, selectedCwd, initialSessionId, onSelectSession]);
|
||||
|
||||
const commitCustomPath = useCallback(() => {
|
||||
const path = customPathValue.trim();
|
||||
if (path) {
|
||||
setSelectedCwd(path);
|
||||
}
|
||||
setCustomPathOpen(false);
|
||||
setCustomPathValue("");
|
||||
setDropdownOpen(false);
|
||||
}, [customPathValue]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
setCustomPathOpen(false);
|
||||
setCustomPathValue("");
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const handleNewSession = useCallback(() => {
|
||||
if (!selectedCwd) return;
|
||||
// Generate a temporary UUID client-side — no backend call needed.
|
||||
// Pi will be spawned lazily when the user sends the first message.
|
||||
const tempId = typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
||||
onNewSession?.(tempId, selectedCwd);
|
||||
}, [selectedCwd, onNewSession]);
|
||||
|
||||
const recentCwds = getRecentCwds(allSessions);
|
||||
const filteredSessions = selectedCwd
|
||||
? allSessions.filter((s) => s.cwd === selectedCwd)
|
||||
: allSessions;
|
||||
|
||||
// Build parent-child tree within the filtered set
|
||||
const sessionTree = buildSessionTree(filteredSessions);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 10px 10px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 15, color: "var(--text)", letterSpacing: "-0.01em" }}>Pi Agent</span>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
disabled={!selectedCwd}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 5,
|
||||
background: "var(--bg-hover)",
|
||||
border: "1px solid var(--border)",
|
||||
color: selectedCwd ? "var(--text-muted)" : "var(--text-dim)",
|
||||
cursor: selectedCwd ? "pointer" : "not-allowed",
|
||||
height: 32,
|
||||
paddingLeft: 10,
|
||||
paddingRight: 12,
|
||||
borderRadius: 7,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.01em",
|
||||
flexShrink: 0,
|
||||
transition: "background 0.12s, color 0.12s, border-color 0.12s",
|
||||
}}
|
||||
title={selectedCwd ? `New session in ${selectedCwd}` : "Select a project first"}
|
||||
onMouseEnter={(e) => {
|
||||
if (!selectedCwd) return;
|
||||
e.currentTarget.style.background = "var(--bg-selected)";
|
||||
e.currentTarget.style.color = "var(--accent)";
|
||||
e.currentTarget.style.borderColor = "rgba(37,99,235,0.35)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "var(--bg-hover)";
|
||||
e.currentTarget.style.color = selectedCwd ? "var(--text-muted)" : "var(--text-dim)";
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<line x1="6" y1="1" x2="6" y2="11" />
|
||||
<line x1="1" y1="6" x2="11" y2="6" />
|
||||
</svg>
|
||||
New
|
||||
</button>
|
||||
<button
|
||||
onClick={() => loadSessions(true)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
background: "var(--bg-hover)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
width: 32, height: 32,
|
||||
borderRadius: 7,
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
transition: "background 0.12s, color 0.12s, border-color 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--bg-selected)";
|
||||
e.currentTarget.style.color = "var(--accent)";
|
||||
e.currentTarget.style.borderColor = "rgba(37,99,235,0.35)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "var(--bg-hover)";
|
||||
e.currentTarget.style.color = "var(--text-muted)";
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
}}
|
||||
title="Refresh"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CWD picker */}
|
||||
<div ref={dropdownRef} style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen((v) => !v)}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 6,
|
||||
padding: "6px 10px",
|
||||
background: "var(--bg-hover)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 7,
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
color: "var(--text)",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="var(--text-muted)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<path d="M1 3.5h10M1 3.5v7h10v-7M1 3.5l1-2h3l1 2" />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
title={selectedCwd ?? ""}
|
||||
>
|
||||
{selectedCwd ? displayCwd(selectedCwd) : "Select project…"}
|
||||
</span>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--text-dim)" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, transform: dropdownOpen ? "rotate(180deg)" : "none", transition: "transform 0.15s" }}>
|
||||
<polyline points="2 3.5 5 6.5 8 3.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 4px)",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 6px 20px rgba(0,0,0,0.10)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{recentCwds.map((cwd) => (
|
||||
<button
|
||||
key={cwd}
|
||||
onClick={() => {
|
||||
setSelectedCwd(cwd);
|
||||
setCustomPathOpen(false);
|
||||
setCustomPathValue("");
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
width: "100%",
|
||||
padding: "8px 10px",
|
||||
background: cwd === selectedCwd ? "var(--bg-selected)" : "none",
|
||||
border: "none",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
color: cwd === selectedCwd ? "var(--text)" : "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
fontSize: 11,
|
||||
fontFamily: "var(--font-mono)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={cwd}
|
||||
>
|
||||
{cwd === selectedCwd && (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<polyline points="1.5 5 4 7.5 8.5 2.5" />
|
||||
</svg>
|
||||
)}
|
||||
{cwd !== selectedCwd && <span style={{ width: 10, flexShrink: 0 }} />}
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{displayCwd(cwd)}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Custom path entry */}
|
||||
{!customPathOpen ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCustomPathOpen(true);
|
||||
setTimeout(() => customPathInputRef.current?.focus(), 0);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
width: "100%",
|
||||
padding: "8px 10px",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" style={{ flexShrink: 0 }}>
|
||||
<line x1="5" y1="1" x2="5" y2="9" />
|
||||
<line x1="1" y1="5" x2="9" y2="5" />
|
||||
</svg>
|
||||
<span>Custom path…</span>
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ padding: "6px 8px", borderTop: recentCwds.length > 0 ? "none" : undefined }}>
|
||||
<input
|
||||
ref={customPathInputRef}
|
||||
value={customPathValue}
|
||||
onChange={(e) => setCustomPathValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitCustomPath();
|
||||
if (e.key === "Escape") {
|
||||
setCustomPathOpen(false);
|
||||
setCustomPathValue("");
|
||||
}
|
||||
}}
|
||||
placeholder="/path/to/project"
|
||||
style={{
|
||||
width: "100%",
|
||||
fontSize: 11,
|
||||
fontFamily: "var(--font-mono)",
|
||||
padding: "5px 8px",
|
||||
border: "1px solid var(--accent)",
|
||||
borderRadius: 5,
|
||||
outline: "none",
|
||||
background: "var(--bg)",
|
||||
color: "var(--text)",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 5, marginTop: 5 }}>
|
||||
<button
|
||||
onClick={commitCustomPath}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "4px 0",
|
||||
background: "var(--accent)",
|
||||
border: "none",
|
||||
borderRadius: 5,
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setCustomPathOpen(false); setCustomPathValue(""); }}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "4px 0",
|
||||
background: "var(--bg-hover)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 5,
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 11,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div style={{ flex: explorerOpen && (selectedCwdProp || selectedCwd) ? "1 1 0" : "1 1 auto", overflowY: "auto", padding: "4px 0", minHeight: 80 }}>
|
||||
{loading && (
|
||||
<div style={{ padding: "16px 14px", color: "var(--text-muted)", fontSize: 12 }}>
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{ padding: "12px 14px", color: "#f87171", fontSize: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && filteredSessions.length === 0 && (
|
||||
<div style={{ padding: "16px 14px", color: "var(--text-muted)", fontSize: 12 }}>
|
||||
No sessions found
|
||||
</div>
|
||||
)}
|
||||
{sessionTree.map((node) => (
|
||||
<SessionTreeItem
|
||||
key={node.session.id}
|
||||
node={node}
|
||||
selectedSessionId={selectedSessionId}
|
||||
onSelectSession={onSelectSession}
|
||||
onRenamed={loadSessions}
|
||||
onSessionDeleted={(id) => {
|
||||
onSessionDeleted?.(id);
|
||||
loadSessions();
|
||||
}}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* File Explorer section */}
|
||||
{(selectedCwdProp || selectedCwd) && (
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: explorerOpen ? "1 1 0" : "0 0 auto",
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setExplorerOpen((v) => !v)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
flex: 1,
|
||||
padding: "6px 10px",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="9" height="9" viewBox="0 0 10 10" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: explorerOpen ? "rotate(90deg)" : "none", transition: "transform 0.15s", flexShrink: 0 }}
|
||||
>
|
||||
<polyline points="3 2 7 5 3 8" />
|
||||
</svg>
|
||||
Explorer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExplorerKey((k) => k + 1)}
|
||||
title="Refresh explorer"
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
width: 26, height: 26, padding: 0, marginRight: 6,
|
||||
background: "none", border: "none",
|
||||
color: "var(--text-dim)", cursor: "pointer",
|
||||
borderRadius: 5,
|
||||
flexShrink: 0,
|
||||
transition: "color 0.12s, background 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--text-muted)"; e.currentTarget.style.background = "var(--bg-hover)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-dim)"; e.currentTarget.style.background = "none"; }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{explorerOpen && (
|
||||
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
|
||||
<FileExplorer
|
||||
cwd={selectedCwdProp ?? selectedCwd!}
|
||||
onOpenFile={onOpenFile ?? (() => {})}
|
||||
refreshKey={explorerKey}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionTreeItem({
|
||||
node,
|
||||
selectedSessionId,
|
||||
onSelectSession,
|
||||
onRenamed,
|
||||
onSessionDeleted,
|
||||
depth,
|
||||
}: {
|
||||
node: SessionTreeNode;
|
||||
selectedSessionId: string | null;
|
||||
onSelectSession: (s: SessionInfo) => void;
|
||||
onRenamed?: () => void;
|
||||
onSessionDeleted?: (id: string) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* Indent line for child sessions */}
|
||||
{depth > 0 && (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: depth * 12 + 6,
|
||||
top: 0, bottom: 0,
|
||||
width: 1,
|
||||
background: "var(--border)",
|
||||
pointerEvents: "none",
|
||||
}} />
|
||||
)}
|
||||
<SessionItem
|
||||
session={node.session}
|
||||
isSelected={node.session.id === selectedSessionId}
|
||||
onClick={() => onSelectSession(node.session)}
|
||||
onRenamed={onRenamed}
|
||||
onDeleted={(id) => onSessionDeleted?.(id)}
|
||||
depth={depth}
|
||||
hasChildren={hasChildren}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={() => setCollapsed((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
{hasChildren && !collapsed && (
|
||||
<div>
|
||||
{node.children.map((child) => (
|
||||
<SessionTreeItem
|
||||
key={child.session.id}
|
||||
node={child}
|
||||
selectedSessionId={selectedSessionId}
|
||||
onSelectSession={onSelectSession}
|
||||
onRenamed={onRenamed}
|
||||
onSessionDeleted={onSessionDeleted}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionItem({
|
||||
session,
|
||||
isSelected,
|
||||
onClick,
|
||||
onRenamed,
|
||||
onDeleted,
|
||||
depth = 0,
|
||||
hasChildren = false,
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
session: SessionInfo;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onRenamed?: () => void;
|
||||
onDeleted?: (id: string) => void;
|
||||
depth?: number;
|
||||
hasChildren?: boolean;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const title = session.name || session.firstMessage.slice(0, 50) || session.id.slice(0, 12);
|
||||
|
||||
const startRename = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setRenameValue(session.name ?? "");
|
||||
setRenaming(true);
|
||||
setTimeout(() => inputRef.current?.select(), 0);
|
||||
}, [session.name]);
|
||||
|
||||
const commitRename = useCallback(async () => {
|
||||
const name = renameValue.trim();
|
||||
setRenaming(false);
|
||||
if (name === (session.name ?? "")) return;
|
||||
try {
|
||||
await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
onRenamed?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [renameValue, session.id, session.name, onRenamed]);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(false);
|
||||
setDeleting(true);
|
||||
try {
|
||||
await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" });
|
||||
onDeleted?.(session.id);
|
||||
} catch {
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [session.id, onDeleted]);
|
||||
|
||||
const handleDeleteCancel = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(false);
|
||||
}, []);
|
||||
|
||||
// Fixed-height outer wrapper — content swaps in place so the list never reflows
|
||||
const ITEM_HEIGHT = 54;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={confirmDelete || renaming ? undefined : onClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => { setHovered(false); }}
|
||||
style={{
|
||||
height: ITEM_HEIGHT,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: depth > 0 ? depth * 12 + 14 : 14,
|
||||
paddingRight: 8,
|
||||
cursor: confirmDelete || renaming ? "default" : "pointer",
|
||||
background: confirmDelete
|
||||
? "rgba(239,68,68,0.06)"
|
||||
: isSelected ? "var(--bg-selected)" : hovered ? "var(--bg-hover)" : "transparent",
|
||||
borderLeft: confirmDelete
|
||||
? "2px solid #ef4444"
|
||||
: isSelected ? "2px solid var(--accent)" : "2px solid transparent",
|
||||
transition: "background 0.1s",
|
||||
opacity: deleting ? 0.5 : 1,
|
||||
gap: 6,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{confirmDelete ? (
|
||||
/* ── Delete confirmation: same height, two flat buttons ── */
|
||||
<>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 12, color: "var(--text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
Delete <span style={{ fontWeight: 600 }}>“{title.slice(0, 22)}{title.length > 22 ? "…" : ""}”</span>?
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 5, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={handleDeleteConfirm}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 4,
|
||||
height: 30, padding: "0 11px",
|
||||
background: "#ef4444", border: "none",
|
||||
borderRadius: 6, color: "#fff",
|
||||
cursor: "pointer", fontSize: 12, fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteCancel}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
height: 30, padding: "0 11px",
|
||||
background: "var(--bg)", border: "1px solid var(--border)",
|
||||
borderRadius: 6, color: "var(--text-muted)",
|
||||
cursor: "pointer", fontSize: 12, fontWeight: 500,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : renaming ? (
|
||||
/* ── Rename: input fills the same row ── */
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
padding: "5px 8px",
|
||||
border: "1px solid var(--accent)",
|
||||
borderRadius: 5,
|
||||
outline: "none",
|
||||
background: "var(--bg)",
|
||||
color: "var(--text)",
|
||||
height: 30,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
/* ── Normal view ── */
|
||||
<>
|
||||
{/* Fork indicator for child sessions */}
|
||||
{depth > 0 && (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<line x1="6" y1="3" x2="6" y2="15" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? 500 : 400,
|
||||
lineHeight: 1.4,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: "var(--text)",
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ marginTop: 2, display: "flex", gap: 8, color: "var(--text-dim)", fontSize: 11 }}>
|
||||
<span title={session.modified}>{formatRelativeTime(session.modified)}</span>
|
||||
<span>{session.messageCount} msgs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle — always visible when has children */}
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleCollapse?.(); }}
|
||||
title={collapsed ? "Expand forks" : "Collapse forks"}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
width: 20, height: 20, padding: 0, flexShrink: 0,
|
||||
background: "none", border: "none",
|
||||
color: "var(--text-dim)", cursor: "pointer",
|
||||
transform: collapsed ? "rotate(-90deg)" : "none",
|
||||
transition: "transform 0.15s",
|
||||
}}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="2 3.5 5 6.5 8 3.5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Action buttons — shown on hover */}
|
||||
{hovered && (
|
||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={startRename}
|
||||
title="Rename"
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
width: 32, height: 32, padding: 0,
|
||||
background: "var(--bg-hover)", border: "1px solid var(--border)",
|
||||
borderRadius: 7, color: "var(--text-muted)",
|
||||
cursor: "pointer", flexShrink: 0,
|
||||
transition: "background 0.12s, color 0.12s, border-color 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--bg-selected)";
|
||||
e.currentTarget.style.color = "var(--accent)";
|
||||
e.currentTarget.style.borderColor = "rgba(37,99,235,0.35)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "var(--bg-hover)";
|
||||
e.currentTarget.style.color = "var(--text-muted)";
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
title="Delete"
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
width: 32, height: 32, padding: 0,
|
||||
background: "var(--bg-hover)", border: "1px solid var(--border)",
|
||||
borderRadius: 7, color: "var(--text-muted)",
|
||||
cursor: "pointer", flexShrink: 0,
|
||||
transition: "background 0.12s, color 0.12s, border-color 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(239,68,68,0.08)";
|
||||
e.currentTarget.style.color = "#ef4444";
|
||||
e.currentTarget.style.borderColor = "rgba(239,68,68,0.35)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "var(--bg-hover)";
|
||||
e.currentTarget.style.color = "var(--text-muted)";
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getFileIcon } from "./FileIcons";
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
type: "chat" | "file";
|
||||
label: string;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
onSelectTab: (id: string) => void;
|
||||
onCloseTab: (id: string) => void;
|
||||
}
|
||||
|
||||
function ChatIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function TabBar({ tabs, activeTabId, onSelectTab, onCloseTab }: Props) {
|
||||
const [hoveredClose, setHoveredClose] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
background: "var(--bg-panel)",
|
||||
overflowX: "auto",
|
||||
flexShrink: 0,
|
||||
height: 36,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
onClick={() => onSelectTab(tab.id)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
height: 36,
|
||||
paddingLeft: 12,
|
||||
paddingRight: tab.type === "chat" ? 12 : 6,
|
||||
borderRight: "1px solid var(--border)",
|
||||
borderTop: isActive ? "2px solid var(--accent)" : "2px solid transparent",
|
||||
background: isActive ? "var(--bg)" : "transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
color: isActive ? "var(--text)" : "var(--text-muted)",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 180,
|
||||
minWidth: 80,
|
||||
flexShrink: 0,
|
||||
userSelect: "none",
|
||||
transition: "background 0.1s, color 0.1s",
|
||||
}}
|
||||
>
|
||||
<span style={{ flexShrink: 0, opacity: isActive ? 1 : 0.7, display: "flex", alignItems: "center" }}>
|
||||
{tab.type === "chat" ? <ChatIcon /> : getFileIcon(tab.label, 13)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
flex: 1,
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
}}
|
||||
title={tab.filePath ?? tab.label}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
{tab.type === "file" && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onCloseTab(tab.id); }}
|
||||
onMouseEnter={() => setHoveredClose(tab.id)}
|
||||
onMouseLeave={() => setHoveredClose(null)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
width: 16, height: 16,
|
||||
background: hoveredClose === tab.id ? "var(--bg-hover)" : "transparent",
|
||||
border: "none",
|
||||
borderRadius: 3,
|
||||
color: hoveredClose === tab.id ? "var(--text)" : "var(--text-dim)",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
transition: "background 0.1s, color 0.1s",
|
||||
}}
|
||||
title="Close"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
||||
<line x1="2" y1="2" x2="8" y2="8" />
|
||||
<line x1="8" y1="2" x2="2" y2="8" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export interface ToolEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tools: ToolEntry[];
|
||||
onToggle: (name: string, active: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ToolPanel({ tools, onToggle, onClose }: Props) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 8px)",
|
||||
right: 0,
|
||||
zIndex: 200,
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
boxShadow: "0 -4px 20px rgba(0,0,0,0.10)",
|
||||
width: 300,
|
||||
maxHeight: 360,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: "10px 14px 8px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
}}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text)" }}>Active Tools</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
color: "var(--text-dim)", padding: 2, lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="2" y1="2" x2="12" y2="12" /><line x1="12" y1="2" x2="2" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||
{tools.length === 0 && (
|
||||
<div style={{ padding: "12px 14px", color: "var(--text-dim)", fontSize: 12 }}>No tools available</div>
|
||||
)}
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 10,
|
||||
padding: "9px 14px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => onToggle(tool.name, !tool.active)}
|
||||
>
|
||||
{/* Toggle */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
marginTop: 1,
|
||||
width: 30, height: 16,
|
||||
borderRadius: 8,
|
||||
background: tool.active ? "var(--accent)" : "var(--border)",
|
||||
position: "relative",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
top: 2,
|
||||
left: tool.active ? 16 : 2,
|
||||
width: 12, height: 12,
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
transition: "left 0.15s",
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.15)",
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: tool.active ? "var(--text)" : "var(--text-muted)" }}>
|
||||
{tool.name}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11, color: "var(--text-dim)", marginTop: 2,
|
||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
}} title={tool.description}>
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ padding: "6px 14px", borderTop: "1px solid var(--border)" }}>
|
||||
<span style={{ fontSize: 10, color: "var(--text-dim)" }}>
|
||||
{tools.filter(t => t.active).length} of {tools.length} active · takes effect on next turn
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AgentMessage } from "./types";
|
||||
|
||||
/**
|
||||
* Pi stores toolCall blocks as {id, name, arguments} but our types use
|
||||
* {toolCallId, toolName, input}. Normalize both file-read and streaming paths.
|
||||
*/
|
||||
export function normalizeToolCalls(msg: AgentMessage): AgentMessage {
|
||||
if (msg.role !== "assistant") return msg;
|
||||
const raw = msg as unknown as { content?: unknown[] };
|
||||
if (!Array.isArray(raw.content)) return msg;
|
||||
const normalized = raw.content.map((block) => {
|
||||
const b = block as Record<string, unknown>;
|
||||
if (b.type !== "toolCall") return b;
|
||||
return {
|
||||
type: "toolCall",
|
||||
toolCallId: b.toolCallId ?? b.id,
|
||||
toolName: b.toolName ?? b.name,
|
||||
input: b.input ?? b.arguments,
|
||||
};
|
||||
});
|
||||
return { ...(msg as object), content: normalized } as unknown as AgentMessage;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { createAgentSession } from "@mariozechner/pi-coding-agent";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface AgentEvent {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type EventListener = (event: AgentEvent) => void;
|
||||
|
||||
// ============================================================================
|
||||
// AgentSessionWrapper
|
||||
// Wraps AgentSession with the same interface the rest of the app expects
|
||||
// ============================================================================
|
||||
|
||||
export class AgentSessionWrapper {
|
||||
private listeners: EventListener[] = [];
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private onDestroyCallback: (() => void) | null = null;
|
||||
private _alive = true;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
constructor(public readonly inner: any) {}
|
||||
|
||||
get sessionId(): string {
|
||||
return this.inner.sessionId;
|
||||
}
|
||||
|
||||
get sessionFile(): string {
|
||||
return this.inner.sessionFile ?? "";
|
||||
}
|
||||
|
||||
isAlive(): boolean {
|
||||
return this._alive;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.unsubscribe = this.inner.subscribe((event: AgentEvent) => {
|
||||
this.resetIdleTimer();
|
||||
for (const l of this.listeners) l(event);
|
||||
});
|
||||
this.resetIdleTimer();
|
||||
}
|
||||
|
||||
private resetIdleTimer(): void {
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer);
|
||||
this.idleTimer = setTimeout(() => this.destroy(), 10 * 60 * 1000);
|
||||
}
|
||||
|
||||
onEvent(listener: EventListener): () => void {
|
||||
this.listeners.push(listener);
|
||||
return () => {
|
||||
const i = this.listeners.indexOf(listener);
|
||||
if (i !== -1) this.listeners.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
onDestroy(cb: () => void): void {
|
||||
this.onDestroyCallback = cb;
|
||||
}
|
||||
|
||||
async send(command: Record<string, unknown>): Promise<unknown> {
|
||||
this.resetIdleTimer();
|
||||
const type = command.type as string;
|
||||
|
||||
switch (type) {
|
||||
case "prompt":
|
||||
// Fire and forget — events come via subscribe
|
||||
this.inner.prompt(command.message as string).catch(() => {});
|
||||
return null;
|
||||
|
||||
case "abort":
|
||||
await this.inner.abort();
|
||||
return null;
|
||||
|
||||
case "get_state": {
|
||||
const model = this.inner.model;
|
||||
return {
|
||||
sessionId: this.inner.sessionId,
|
||||
sessionFile: this.inner.sessionFile ?? "",
|
||||
isStreaming: this.inner.isStreaming,
|
||||
isCompacting: this.inner.isCompacting ?? false,
|
||||
thinkingLevel: this.inner.thinkingLevel ?? "off",
|
||||
autoCompactionEnabled: this.inner.autoCompactionEnabled ?? false,
|
||||
autoRetryEnabled: this.inner.autoRetryEnabled ?? false,
|
||||
model: model ? { id: model.id, provider: model.provider } : undefined,
|
||||
messageCount: 0,
|
||||
pendingMessageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
case "set_model": {
|
||||
const { provider, modelId } = command as { provider: string; modelId: string };
|
||||
const registry = this.inner.modelRegistry;
|
||||
const model = registry?.find(provider, modelId);
|
||||
if (!model) throw new Error(`Model not found: ${provider}/${modelId}`);
|
||||
await this.inner.setModel(model);
|
||||
return model;
|
||||
}
|
||||
|
||||
case "fork": {
|
||||
const result = await this.inner.fork(command.entryId as string);
|
||||
if (result.cancelled) return { cancelled: true };
|
||||
// After fork, inner.sessionId is the new session's id.
|
||||
// Capture it before destroy() clears state.
|
||||
const newSessionId = this.inner.sessionId as string;
|
||||
// Destroy this wrapper immediately so the next request for the
|
||||
// original session reloads a clean AgentSession from its file.
|
||||
this.destroy();
|
||||
return { cancelled: false, newSessionId };
|
||||
}
|
||||
|
||||
case "navigate_tree": {
|
||||
const result = await this.inner.navigateTree(command.targetId as string, {});
|
||||
return { cancelled: result.cancelled };
|
||||
}
|
||||
|
||||
case "set_thinking_level": {
|
||||
this.inner.setThinkingLevel(command.level as string);
|
||||
return null;
|
||||
}
|
||||
|
||||
case "compact": {
|
||||
const settings = this.inner.settingsManager.getCompactionSettings();
|
||||
const keepRecentTokens: number = settings?.keepRecentTokens ?? 20000;
|
||||
const usage = this.inner.getContextUsage();
|
||||
const currentTokens: number | null = usage?.tokens ?? null;
|
||||
if (currentTokens !== null && currentTokens < keepRecentTokens) {
|
||||
throw new Error("Conversation too short to compact");
|
||||
}
|
||||
const result = await this.inner.compact(command.customInstructions as string | undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
case "set_auto_compaction": {
|
||||
this.inner.setAutoCompactionEnabled(command.enabled as boolean);
|
||||
return null;
|
||||
}
|
||||
|
||||
case "steer": {
|
||||
await this.inner.steer(command.message as string);
|
||||
return null;
|
||||
}
|
||||
|
||||
case "follow_up": {
|
||||
await this.inner.followUp(command.message as string);
|
||||
return null;
|
||||
}
|
||||
|
||||
case "get_tools": {
|
||||
const all = this.inner.getAllTools() as Array<{ name: string; description: string }>;
|
||||
const active = new Set<string>(this.inner.getActiveToolNames() as string[]);
|
||||
return all.map((t: { name: string; description: string }) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
active: active.has(t.name),
|
||||
}));
|
||||
}
|
||||
|
||||
case "set_tools": {
|
||||
this.inner.setActiveToolsByName(command.toolNames as string[]);
|
||||
return null;
|
||||
}
|
||||
|
||||
case "abort_compaction": {
|
||||
this.inner.abortCompaction();
|
||||
return null;
|
||||
}
|
||||
|
||||
case "set_auto_retry": {
|
||||
this.inner.setAutoRetryEnabled(command.enabled as boolean);
|
||||
return null;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported command: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (!this._alive) return;
|
||||
this._alive = false;
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer);
|
||||
this.unsubscribe?.();
|
||||
this.onDestroyCallback?.();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session registry
|
||||
// ============================================================================
|
||||
|
||||
declare global {
|
||||
var __piSessions: Map<string, AgentSessionWrapper> | undefined;
|
||||
var __piStartLocks: Map<string, Promise<{ session: AgentSessionWrapper; realSessionId: string }>> | undefined;
|
||||
}
|
||||
|
||||
function getRegistry(): Map<string, AgentSessionWrapper> {
|
||||
if (!globalThis.__piSessions) {
|
||||
globalThis.__piSessions = new Map();
|
||||
const cleanup = () => globalThis.__piSessions?.forEach((s) => s.destroy());
|
||||
process.once("exit", cleanup);
|
||||
process.once("SIGINT", cleanup);
|
||||
process.once("SIGTERM", cleanup);
|
||||
}
|
||||
return globalThis.__piSessions;
|
||||
}
|
||||
|
||||
function getLocks(): Map<string, Promise<{ session: AgentSessionWrapper; realSessionId: string }>> {
|
||||
if (!globalThis.__piStartLocks) globalThis.__piStartLocks = new Map();
|
||||
return globalThis.__piStartLocks;
|
||||
}
|
||||
|
||||
function getAgentDir(): string {
|
||||
const env = process.env.PI_CODING_AGENT_DIR;
|
||||
if (env) {
|
||||
if (env === "~") return homedir();
|
||||
if (env.startsWith("~/")) return homedir() + env.slice(1);
|
||||
return env;
|
||||
}
|
||||
return join(homedir(), ".pi", "agent");
|
||||
}
|
||||
|
||||
export function getRpcSession(sessionId: string): AgentSessionWrapper | undefined {
|
||||
return getRegistry().get(sessionId);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get or create an AgentSession for the given session.
|
||||
* For new sessions (sessionFile === ""), pi generates its own id.
|
||||
*/
|
||||
export async function startRpcSession(
|
||||
sessionId: string,
|
||||
sessionFile: string,
|
||||
cwd: string
|
||||
): Promise<{ session: AgentSessionWrapper; realSessionId: string }> {
|
||||
const registry = getRegistry();
|
||||
const locks = getLocks();
|
||||
|
||||
const existing = registry.get(sessionId);
|
||||
if (existing?.isAlive()) return { session: existing, realSessionId: sessionId };
|
||||
|
||||
const inflight = locks.get(sessionId);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const starting = (async () => {
|
||||
const agentDir = getAgentDir();
|
||||
|
||||
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
||||
// Pass undefined for sessionDir so pi derives it from cwd (new) or file path (existing)
|
||||
const sessionManager = sessionFile
|
||||
? SessionManager.open(sessionFile, undefined)
|
||||
: SessionManager.create(cwd, undefined);
|
||||
|
||||
const { session: inner } = await createAgentSession({ cwd, agentDir, sessionManager });
|
||||
const wrapper = new AgentSessionWrapper(inner);
|
||||
wrapper.start();
|
||||
|
||||
const realSessionId = inner.sessionId as string;
|
||||
|
||||
wrapper.onDestroy(() => registry.delete(realSessionId));
|
||||
registry.set(realSessionId, wrapper);
|
||||
|
||||
return { session: wrapper, realSessionId };
|
||||
})().finally(() => locks.delete(sessionId));
|
||||
|
||||
locks.set(sessionId, starting);
|
||||
return starting;
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
import type {
|
||||
FileEntry,
|
||||
SessionEntry,
|
||||
SessionHeader,
|
||||
SessionInfo,
|
||||
SessionTreeNode,
|
||||
SessionMessageEntry,
|
||||
SessionInfoEntry,
|
||||
LabelEntry,
|
||||
AgentMessage,
|
||||
SessionContext,
|
||||
CompactionEntry,
|
||||
TextContent,
|
||||
} from "./types";
|
||||
import { normalizeToolCalls } from "./normalize";
|
||||
|
||||
function getAgentDir(): string {
|
||||
const envDir = process.env.PI_CODING_AGENT_DIR;
|
||||
if (envDir) {
|
||||
if (envDir === "~") return homedir();
|
||||
if (envDir.startsWith("~/")) return homedir() + envDir.slice(1);
|
||||
return envDir;
|
||||
}
|
||||
return join(homedir(), ".pi", "agent");
|
||||
}
|
||||
|
||||
export function getSessionsDir(): string {
|
||||
return join(getAgentDir(), "sessions");
|
||||
}
|
||||
|
||||
// Build a map of modelId -> friendly name from ~/.pi/agent/models.json
|
||||
let _modelNameCache: Map<string, string> | null = null;
|
||||
export function getModelNameMap(): Map<string, string> {
|
||||
if (_modelNameCache) return _modelNameCache;
|
||||
_modelNameCache = new Map();
|
||||
const path = join(getAgentDir(), "models.json");
|
||||
if (!existsSync(path)) return _modelNameCache;
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(path, "utf8")) as {
|
||||
providers?: Record<string, { models?: { id: string; name?: string }[] }>;
|
||||
};
|
||||
for (const provider of Object.values(data.providers ?? {})) {
|
||||
for (const model of provider.models ?? []) {
|
||||
if (model.id && model.name) _modelNameCache.set(model.id, model.name);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return _modelNameCache;
|
||||
}
|
||||
|
||||
export function parseSessionFile(filePath: string): FileEntry[] {
|
||||
if (!existsSync(filePath)) return [];
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const entries: FileEntry[] = [];
|
||||
for (const line of content.trim().split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
entries.push(JSON.parse(line) as FileEntry);
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function extractTextContent(message: AgentMessage): string {
|
||||
const content = (message as { content: unknown }).content;
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((b): b is TextContent => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// Extract session UUID from a session file path like ".../2026-03-18T12-11-02-213Z_20a1e7e5-991f-4b91-856d-14dd2d256f20.jsonl"
|
||||
function extractSessionIdFromPath(filePath: string): string | undefined {
|
||||
const base = filePath.replace(/\\/g, "/").split("/").pop() ?? "";
|
||||
// format: <timestamp>_<uuid>.jsonl
|
||||
const match = base.match(/_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
export function buildSessionInfo(filePath: string): SessionInfo | null {
|
||||
try {
|
||||
const entries = parseSessionFile(filePath);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
// Skip files without a valid session header (incomplete fork artifacts)
|
||||
const maybeHeader = entries[0] as SessionHeader;
|
||||
if (maybeHeader.type !== "session") return null;
|
||||
|
||||
const stats = statSync(filePath);
|
||||
const id = maybeHeader.id;
|
||||
const cwd = maybeHeader.cwd ?? "";
|
||||
const created = maybeHeader.timestamp;
|
||||
const parentSessionId = maybeHeader.parentSession
|
||||
? extractSessionIdFromPath(maybeHeader.parentSession)
|
||||
: undefined;
|
||||
|
||||
let messageCount = 0;
|
||||
let firstMessage = "";
|
||||
let name: string | undefined;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "session_info") {
|
||||
const e = entry as SessionInfoEntry;
|
||||
if (e.name) name = e.name.trim();
|
||||
}
|
||||
if (entry.type !== "message") continue;
|
||||
messageCount++;
|
||||
const msg = (entry as SessionMessageEntry).message;
|
||||
if (msg.role !== "user" && msg.role !== "assistant") continue;
|
||||
const text = extractTextContent(msg);
|
||||
if (!firstMessage && msg.role === "user") firstMessage = text;
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
id,
|
||||
cwd,
|
||||
name,
|
||||
created,
|
||||
modified: stats.mtime.toISOString(),
|
||||
messageCount,
|
||||
firstMessage: firstMessage || "(no messages)",
|
||||
parentSessionId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAllSessions(): Promise<SessionInfo[]> {
|
||||
const sessionsDir = getSessionsDir();
|
||||
const sessions: SessionInfo[] = [];
|
||||
if (!existsSync(sessionsDir)) return sessions;
|
||||
|
||||
try {
|
||||
const dirs = readdirSync(sessionsDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => join(sessionsDir, e.name));
|
||||
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.map((f) => join(dir, f));
|
||||
for (const file of files) {
|
||||
const info = buildSessionInfo(file);
|
||||
if (info) sessions.push(info);
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable dir
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
||||
return sessions;
|
||||
}
|
||||
|
||||
export function getSessionEntries(filePath: string): SessionEntry[] {
|
||||
const entries = parseSessionFile(filePath);
|
||||
return entries.filter((e): e is SessionEntry => e.type !== "session");
|
||||
}
|
||||
|
||||
export function buildTree(entries: SessionEntry[]): SessionTreeNode[] {
|
||||
const labelsById = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "label") {
|
||||
const l = entry as LabelEntry;
|
||||
if (l.label) labelsById.set(l.targetId, l.label);
|
||||
else labelsById.delete(l.targetId);
|
||||
}
|
||||
}
|
||||
|
||||
const nodeMap = new Map<string, SessionTreeNode>();
|
||||
const roots: SessionTreeNode[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
nodeMap.set(entry.id, { entry, children: [], label: labelsById.get(entry.id) });
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const node = nodeMap.get(entry.id)!;
|
||||
if (!entry.parentId) {
|
||||
roots.push(node);
|
||||
} else {
|
||||
const parent = nodeMap.get(entry.parentId);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sort children by timestamp
|
||||
const stack = [...roots];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
node.children.sort(
|
||||
(a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
||||
);
|
||||
stack.push(...node.children);
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function buildSessionContext(
|
||||
entries: SessionEntry[],
|
||||
leafId?: string | null
|
||||
): SessionContext {
|
||||
const byId = new Map<string, SessionEntry>();
|
||||
for (const entry of entries) {
|
||||
byId.set(entry.id, entry);
|
||||
}
|
||||
|
||||
let leaf: SessionEntry | undefined;
|
||||
if (leafId === null) {
|
||||
return { messages: [], entryIds: [], thinkingLevel: "off", model: null };
|
||||
}
|
||||
if (leafId) {
|
||||
leaf = byId.get(leafId);
|
||||
}
|
||||
if (!leaf) {
|
||||
leaf = entries[entries.length - 1];
|
||||
}
|
||||
if (!leaf) {
|
||||
return { messages: [], entryIds: [], thinkingLevel: "off", model: null };
|
||||
}
|
||||
|
||||
// walk from leaf to root
|
||||
const path: SessionEntry[] = [];
|
||||
let current: SessionEntry | undefined = leaf;
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
|
||||
let thinkingLevel = "off";
|
||||
let model: { provider: string; modelId: string } | null = null;
|
||||
let compaction: CompactionEntry | null = null;
|
||||
|
||||
for (const entry of path) {
|
||||
if (entry.type === "thinking_level_change") {
|
||||
thinkingLevel = entry.thinkingLevel;
|
||||
} else if (entry.type === "model_change") {
|
||||
model = { provider: entry.provider, modelId: entry.modelId };
|
||||
} else if (entry.type === "message" && (entry as SessionMessageEntry).message.role === "assistant") {
|
||||
const msg = (entry as SessionMessageEntry).message as { provider: string; model: string };
|
||||
model = { provider: msg.provider, modelId: msg.model };
|
||||
} else if (entry.type === "compaction") {
|
||||
compaction = entry as CompactionEntry;
|
||||
}
|
||||
}
|
||||
|
||||
const messages: AgentMessage[] = [];
|
||||
const entryIds: string[] = [];
|
||||
|
||||
const appendMessage = (entry: SessionEntry) => {
|
||||
if (entry.type === "message") {
|
||||
messages.push(normalizeToolCalls((entry as SessionMessageEntry).message));
|
||||
entryIds.push(entry.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (compaction) {
|
||||
// Inject compaction summary as a virtual user message matching agent's format
|
||||
const summaryMsg: AgentMessage = {
|
||||
role: "user",
|
||||
content: `*The conversation history before this point was compacted into the following summary:*\n\n${compaction.summary}`,
|
||||
timestamp: new Date(compaction.timestamp).getTime(),
|
||||
};
|
||||
messages.push(summaryMsg);
|
||||
entryIds.push(compaction.id);
|
||||
|
||||
const compactionIdx = path.findIndex(
|
||||
(e) => e.type === "compaction" && e.id === compaction!.id
|
||||
);
|
||||
// Find firstKeptEntryId before the compaction node; if not found, skip all pre-compaction entries
|
||||
const firstKeptIdx = path.findIndex(
|
||||
(e, i) => i < compactionIdx && e.id === compaction!.firstKeptEntryId
|
||||
);
|
||||
const startIdx = firstKeptIdx >= 0 ? firstKeptIdx : compactionIdx;
|
||||
for (let i = startIdx; i < compactionIdx; i++) {
|
||||
appendMessage(path[i]);
|
||||
}
|
||||
for (let i = compactionIdx + 1; i < path.length; i++) {
|
||||
appendMessage(path[i]);
|
||||
}
|
||||
} else {
|
||||
for (const entry of path) {
|
||||
appendMessage(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return { messages, entryIds, thinkingLevel, model };
|
||||
}
|
||||
|
||||
export function getLeafId(entries: SessionEntry[]): string | null {
|
||||
if (entries.length === 0) return null;
|
||||
return entries[entries.length - 1].id;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session path cache: sessionId → absolute file path
|
||||
// Stored in globalThis for hot-reload safety (same as rpc-manager registry)
|
||||
// ============================================================================
|
||||
declare global {
|
||||
var __piSessionPathCache: Map<string, string> | undefined;
|
||||
}
|
||||
|
||||
function getPathCache(): Map<string, string> {
|
||||
if (!globalThis.__piSessionPathCache) globalThis.__piSessionPathCache = new Map();
|
||||
return globalThis.__piSessionPathCache;
|
||||
}
|
||||
|
||||
export function invalidateSessionPathCache(sessionId: string): void {
|
||||
getPathCache().delete(sessionId);
|
||||
}
|
||||
|
||||
export function resolveSessionPath(sessionId: string): string | null {
|
||||
const cache = getPathCache();
|
||||
const cached = cache.get(sessionId);
|
||||
if (cached && existsSync(cached)) return cached;
|
||||
|
||||
const sessionsDir = getSessionsDir();
|
||||
if (!existsSync(sessionsDir)) return null;
|
||||
try {
|
||||
const dirs = readdirSync(sessionsDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => join(sessionsDir, e.name));
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
||||
for (const file of files) {
|
||||
const fullPath = join(dir, file);
|
||||
// Read only first line to check header id
|
||||
const firstLine = readFileSync(fullPath, "utf8").split("\n")[0];
|
||||
try {
|
||||
const header = JSON.parse(firstLine) as SessionHeader;
|
||||
if (header.type === "session" && header.id === sessionId) {
|
||||
cache.set(sessionId, fullPath);
|
||||
return fullPath;
|
||||
}
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
// Types mirrored from pi-mono coding-agent session-manager
|
||||
|
||||
export interface SessionHeader {
|
||||
type: "session";
|
||||
version?: number;
|
||||
id: string;
|
||||
timestamp: string;
|
||||
cwd: string;
|
||||
parentSession?: string;
|
||||
}
|
||||
|
||||
export interface SessionEntryBase {
|
||||
type: string;
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface TextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ImageContent {
|
||||
type: "image";
|
||||
source: {
|
||||
type: "base64" | "url";
|
||||
media_type?: string;
|
||||
data?: string;
|
||||
url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ThinkingContent {
|
||||
type: "thinking";
|
||||
thinking: string;
|
||||
}
|
||||
|
||||
export interface ToolCallContent {
|
||||
type: "toolCall";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type AssistantContentBlock = TextContent | ImageContent | ThinkingContent | ToolCallContent;
|
||||
|
||||
export interface UserMessage {
|
||||
role: "user";
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface AssistantMessage {
|
||||
role: "assistant";
|
||||
content: AssistantContentBlock[];
|
||||
model: string;
|
||||
provider: string;
|
||||
stopReason?: string;
|
||||
errorMessage?: string;
|
||||
timestamp?: number;
|
||||
usage?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cost: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolResultMessage {
|
||||
role: "toolResult";
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
content: (TextContent | ImageContent)[];
|
||||
isError?: boolean;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface CustomMessage {
|
||||
role: "custom";
|
||||
customType: string;
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
display: boolean;
|
||||
details?: unknown;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export type AgentMessage = UserMessage | AssistantMessage | ToolResultMessage | CustomMessage;
|
||||
|
||||
export interface SessionMessageEntry extends SessionEntryBase {
|
||||
type: "message";
|
||||
message: AgentMessage;
|
||||
}
|
||||
|
||||
export interface ThinkingLevelChangeEntry extends SessionEntryBase {
|
||||
type: "thinking_level_change";
|
||||
thinkingLevel: string;
|
||||
}
|
||||
|
||||
export interface ModelChangeEntry extends SessionEntryBase {
|
||||
type: "model_change";
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export interface CompactionEntry extends SessionEntryBase {
|
||||
type: "compaction";
|
||||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
details?: unknown;
|
||||
fromHook?: boolean;
|
||||
}
|
||||
|
||||
export interface BranchSummaryEntry extends SessionEntryBase {
|
||||
type: "branch_summary";
|
||||
fromId: string;
|
||||
summary: string;
|
||||
details?: unknown;
|
||||
fromHook?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomEntry extends SessionEntryBase {
|
||||
type: "custom";
|
||||
customType: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface CustomMessageEntry extends SessionEntryBase {
|
||||
type: "custom_message";
|
||||
customType: string;
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
details?: unknown;
|
||||
display: boolean;
|
||||
}
|
||||
|
||||
export interface LabelEntry extends SessionEntryBase {
|
||||
type: "label";
|
||||
targetId: string;
|
||||
label: string | undefined;
|
||||
}
|
||||
|
||||
export interface SessionInfoEntry extends SessionEntryBase {
|
||||
type: "session_info";
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export type SessionEntry =
|
||||
| SessionMessageEntry
|
||||
| ThinkingLevelChangeEntry
|
||||
| ModelChangeEntry
|
||||
| CompactionEntry
|
||||
| BranchSummaryEntry
|
||||
| CustomEntry
|
||||
| CustomMessageEntry
|
||||
| LabelEntry
|
||||
| SessionInfoEntry;
|
||||
|
||||
export type FileEntry = SessionHeader | SessionEntry;
|
||||
|
||||
export interface SessionTreeNode {
|
||||
entry: SessionEntry;
|
||||
children: SessionTreeNode[];
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
path: string;
|
||||
id: string;
|
||||
cwd: string;
|
||||
name?: string;
|
||||
created: string;
|
||||
modified: string;
|
||||
messageCount: number;
|
||||
firstMessage: string;
|
||||
parentSessionId?: string; // set if this session was forked from another
|
||||
}
|
||||
|
||||
export interface SessionContext {
|
||||
messages: AgentMessage[];
|
||||
entryIds: string[]; // parallel to messages — the session entry id for each message
|
||||
thinkingLevel: string;
|
||||
model: { provider: string; modelId: string } | null;
|
||||
}
|
||||
|
||||
// RPC types
|
||||
export interface RpcSessionState {
|
||||
model?: { provider: string; id: string; contextWindow?: number };
|
||||
thinkingLevel: string;
|
||||
isStreaming: boolean;
|
||||
isCompacting: boolean;
|
||||
sessionFile?: string;
|
||||
sessionId: string;
|
||||
sessionName?: string;
|
||||
messageCount: number;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["@mariozechner/pi-coding-agent", "@mariozechner/pi-ai"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+9518
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "pi-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3030",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.60.0",
|
||||
"next": "15.1.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.7",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,15 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user