feat: pi-web - Next.js web interface for pi coding agent

This commit is contained in:
Alex Yang committed 2026-03-22 21:42:31 +08:00
commit 95c7a6553f
45 files changed
+17428

No files matched your search

+41
View File
@@ -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
+455
View File
@@ -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.
+36
View File
@@ -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.
+61
View File
@@ -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",
},
});
}
+57
View File
@@ -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 });
}
}
+29
View File
@@ -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 });
}
}
+125
View File
@@ -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 });
}
}
+8
View File
@@ -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) });
}
+29
View File
@@ -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 });
}
}
+123
View File
@@ -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 });
}
}
+5
View File
@@ -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 });
}
+14
View File
@@ -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 }
);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+110
View File
@@ -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; }
}
+21
View File
@@ -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>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import { AppShell } from "@/components/AppShell";
export default function Home() {
return (
<Suspense>
<AppShell />
</Suspense>
);
}
+1489
View File
File diff suppressed because it is too large. Load diff
+169
View File
@@ -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>
);
}
+274
View File
@@ -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>
);
}
+494
View File
@@ -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>
);
}
+345
View File
@@ -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;
}
+628
View File
@@ -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>
);
}
+211
View File
@@ -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>
);
}
+241
View File
@@ -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} />;
}
}
+174
View File
@@ -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>
);
}
+597
View File
@@ -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>
);
}
+939
View File
@@ -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 }}>&ldquo;{title.slice(0, 22)}{title.length > 22 ? "…" : ""}&rdquo;</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>
);
}
+116
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+11
View File
@@ -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;
+22
View File
@@ -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;
}
+276
View File
@@ -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;
}
+366
View File
@@ -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
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@mariozechner/pi-coding-agent", "@mariozechner/pi-ai"],
};
export default nextConfig;
+9518
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -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"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+15
View File
@@ -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;
+40
View File
@@ -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"
]
}