Dench workspace: file manager, relation resolution, and chat refactor

File Manager & Filesystem Operations:
- Add FileManagerTree component with drag-and-drop (dnd-kit), inline
  rename, right-click context menu, and compact sidebar mode
- Add context-menu component (open, new file/folder, rename, duplicate,
  copy, paste, move, delete) rendered via portal
- Add InlineRename component with validation and shake-on-error animation
- Add useWorkspaceWatcher hook with SSE live-reload and polling fallback
- Add API routes: mkdir, rename, copy, move, watch (SSE file-change
  events), and DELETE on /api/workspace/file with system-file protection
- Add safeResolveNewPath and isSystemFile helpers to workspace lib
- Replace inline WorkspaceTreeNode in sidebar with shared FileManagerTree
  (compact mode), add workspace refresh callback

Object Relation Resolution:
- Resolve relation fields to human-readable display labels server-side
  (resolveRelationLabels, resolveDisplayField helpers)
- Add reverse relation discovery (findReverseRelations) — surfaces
  incoming links from other objects
- Add display_field column migration (idempotent ALTER TABLE) and
  PATCH /api/workspace/objects/[name]/display-field endpoint
- Enrich object API response with relationLabels, reverseRelations,
  effectiveDisplayField, and related_object_name per field
- Add RelationCell, RelationChip, ReverseRelationCell, LinkIcon
  components to object-table with clickable cross-object navigation
- Add relation label rendering to kanban cards
- Extract ObjectView component in workspace page with display-field
  selector dropdown and relation/reverse-relation badge counts

Chat Panel Extraction:
- Extract chat logic from page.tsx into standalone ChatPanel component
  with forwardRef/useImperativeHandle for session control
- ChatPanel supports file-scoped sessions (filePath param) and
  context-aware file chat sidebar
- Simplify page.tsx to thin orchestrator delegating to ChatPanel
- Add filePath filter to GET /api/web-sessions for scoped session lists

Dependencies:
- Add @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities
- Add duckdbExec and parseRelationValue to workspace lib

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kumarabhirup 2026-02-11 19:22:53 -08:00
parent 49d05a0b1e
commit 6d8623b00f
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
27 changed files with 4542 additions and 602 deletions

View File

@ -0,0 +1,138 @@
---
name: File chat sidebar
overview: Add a collapsible chat sidebar to the workspace file view that lets users chat with the agent about the currently open file, with file-scoped sessions stored separately from the main chat list.
todos:
- id: extract-chat-panel
content: Extract ChatPanel component from page.tsx with fileContext prop support
status: in_progress
- id: simplify-home-page
content: Simplify page.tsx to render Sidebar + ChatPanel
status: pending
- id: tag-file-sessions
content: Add filePath field to WebSessionMeta and filtering to GET /api/web-sessions
status: pending
- id: workspace-chat-sidebar
content: Add collapsible ChatPanel sidebar to workspace page with file context
status: pending
- id: live-reload
content: Re-fetch file content after agent finishes streaming to show edits live
status: pending
isProject: false
---
# File Chat Sidebar for Workspace
## Architecture
The workspace page layout changes from `[sidebar | content]` to `[sidebar | content | chat-panel]`. The chat panel reuses the same `useChat` + `ChatMessage` + session persistence logic from the main chat page.
```mermaid
graph LR
subgraph workspace [Workspace Page Layout]
WS[WorkspaceSidebar_260px] --> MC[MainContent_flex1]
MC --> CP[ChatPanel_380px]
end
subgraph storage [Session Storage]
idx[index.json] -->|filePath field| fileScoped[File-scoped sessions]
idx -->|no filePath| globalSessions[Global sessions]
end
CP -->|POST /api/chat| agent[Agent Runner]
CP -->|file context in message| agent
```
## Step 1: Extract `ChatPanel` from `page.tsx`
Extract the entire chat UI (messages list, input form, session management, `useChat`, streaming status) from [apps/web/app/page.tsx](apps/web/app/page.tsx) into a new reusable component:
**New file:** `apps/web/app/components/chat-panel.tsx`
```typescript
type ChatPanelProps = {
/** When set, scopes sessions to this file and prepends content as context */
fileContext?: { path: string; content: string; filename: string };
/** External session list (for sidebar session list) */
sessions?: WebSessionMeta[];
onSessionsChange?: () => void;
/** Compact mode for workspace sidebar (no lobster, smaller empty state) */
compact?: boolean;
};
```
- Internally uses `useChat` from `@ai-sdk/react` with `DefaultChatTransport`
- Manages its own `currentSessionId`, `savedMessageIdsRef`, `input`, etc. (same logic as `page.tsx`)
- When `fileContext` is provided:
- The first message in each session is prefixed with: `"[Context: file '{path}']\n\n{content}\n\n---\n\nUser question: {userText}"` -- subsequent messages just send `userText` as-is (the agent already has context from the conversation)
- Session creation passes `filePath` to the API
- Renders: header bar (with session title / status), scrollable message list using `ChatMessage`, error bar, input form
Then **simplify `page.tsx**` to just:
```tsx
<Sidebar ... />
<ChatPanel />
```
## Step 2: Tag file sessions in web-sessions API
Modify [apps/web/app/api/web-sessions/route.ts](apps/web/app/api/web-sessions/route.ts):
- Add optional `filePath` to `WebSessionMeta` type
- `POST` accepts `filePath` in the body and stores it in the index
- `GET` accepts `?filePath=...` query param:
- If `filePath` is set: returns only sessions where `meta.filePath === filePath`
- If `filePath` is absent: returns only sessions where `meta.filePath` is falsy (excludes file-scoped sessions from the main list)
This single change means the main chat sidebar automatically stops showing file sessions, with no other code changes needed.
## Step 3: Add chat sidebar to workspace page
Modify [apps/web/app/workspace/page.tsx](apps/web/app/workspace/page.tsx):
- Add a collapsible right panel (380px) that renders `ChatPanel` with `fileContext`
- The panel appears when a file or document is selected (content kinds: `"document"`, `"file"`)
- Add a toggle button in the breadcrumbs bar to show/hide the chat panel
- Pass the file's `content`, `path`, and `filename` as `fileContext`
- The panel shows a mini session list at top (file-scoped sessions only) and the chat below
Layout becomes:
```
+------------------+------------------------+-------------------+
| WorkspaceSidebar | Content (flex-1) | ChatPanel (380px) |
| (260px) | | [toggle] |
+------------------+------------------------+-------------------+
```
## Step 4: File-scoped session list inside ChatPanel
Inside `ChatPanel`, when `fileContext` is provided:
- Fetch sessions filtered by `filePath` via `GET /api/web-sessions?filePath=...`
- Show a compact session list (just titles, clickable) above the messages area
- "New chat" button creates a new file-scoped session
- Selecting a session loads its messages (same logic as main page's `handleSessionSelect`)
## Step 5: Live file reload after agent edits
When the agent finishes streaming (status goes from `streaming` -> `ready`) and `fileContext` is provided:
- Re-fetch the file content via `GET /api/workspace/file?path=...`
- Call a callback `onFileChanged?.(newContent)` so the workspace page can update `ContentState` without a full reload
- This makes edits appear live in the file viewer/document view next to the chat
## Key files touched
| File | Change |
| ---------------------------------------- | ------------------------------------ |
| `apps/web/app/components/chat-panel.tsx` | **New** -- extracted chat UI + logic |
| `apps/web/app/page.tsx` | Simplify to use `ChatPanel` |
| `apps/web/app/api/web-sessions/route.ts` | Add `filePath` field + filtering |
| `apps/web/app/workspace/page.tsx` | Add right chat sidebar with toggle |
## What stays the same (reused as-is)
- `ChatMessage` component -- no changes
- `ChainOfThought` component -- no changes
- `/api/chat` route + `agent-runner.ts` -- no changes (file context goes in the message text)
- `/api/web-sessions/[id]` and `/api/web-sessions/[id]/messages` routes -- no changes
- `useChat` from `@ai-sdk/react` -- same transport, same everything

View File

@ -0,0 +1,258 @@
---
name: Reports Analytics Layer
overview: "Add a generative-UI reports feature to the Dench web app: the agent creates JSON report definitions with SQL queries and chart configs, rendered live via Recharts in both the workspace view and inline in chat as artifacts."
todos:
- id: recharts-dep
content: Add recharts dependency to apps/web/package.json
status: completed
- id: chart-panel
content: Create ChartPanel component supporting bar/line/area/pie/donut/radar/scatter/funnel via Recharts with CSS variable theming
status: completed
- id: filter-bar
content: Create FilterBar component with dateRange, select, multiSelect filter types; fetches options via SQL
status: completed
- id: report-viewer
content: "Create ReportViewer component: loads report config, executes panel SQL with filter injection, renders ChartPanel grid"
status: completed
- id: report-execute-api
content: Create POST /api/workspace/reports/execute route for SQL execution with filter clause injection
status: completed
- id: workspace-report-type
content: Add report content type to workspace/page.tsx ContentState + ContentRenderer; detect .report.json in tree API
status: completed
- id: knowledge-tree-report
content: Add report node type + icon to knowledge-tree.tsx and tree/route.ts
status: completed
- id: chat-artifact
content: Modify chat-message.tsx to detect report-json fenced blocks and render inline ReportCard
status: completed
- id: report-card
content: Create ReportCard component for compact inline chart rendering in chat with Pin/Open actions
status: completed
- id: sidebar-reports
content: Add Reports section to sidebar.tsx listing .report.json files from workspace tree
status: completed
- id: skill-update
content: Add Section 13 (Report Generation) to skills/dench/SKILL.md with schema, examples, and agent instructions
status: completed
isProject: false
---
# Reports / Analytics Layer for Dench Workspace
## Architecture
Reports are **JSON config files** (`.report.json`) that declare SQL queries to run against `workspace.duckdb` and how to visualize the results. The web app executes SQL at render time (live data), renders charts via Recharts, and supports interactive filters.
Reports surface in **three places**:
1. **Workspace view** -- full-page report dashboard when clicking a report in the tree
2. **Chat** -- inline chart artifact when the agent generates a report in conversation
3. **Sidebar** -- reports listed under a new "Reports" section
```mermaid
flowchart LR
subgraph agent [Agent]
skill[Dench Skill]
skill -->|"write .report.json"| fs[Filesystem]
skill -->|"emit report block in text"| chat[Chat Stream]
end
subgraph web [Web App]
fs --> treeAPI["/api/workspace/tree"]
fs --> reportAPI["/api/workspace/reports"]
reportAPI --> execAPI["/api/workspace/query"]
execAPI --> duckdb["workspace.duckdb"]
treeAPI --> sidebar[Sidebar + KnowledgeTree]
reportAPI --> workspaceView[ReportViewer in Workspace]
chat --> chatUI["ChatMessage with inline ReportCard"]
chatUI --> execAPI
workspaceView --> recharts[Recharts Charts]
chatUI --> recharts
end
```
---
## Report Definition Format
Stored as `.report.json` files in `dench/reports/` (or nested under any knowledge path). Agent generates these via the `write` tool.
```json
{
"version": 1,
"title": "Deals Pipeline",
"description": "Revenue breakdown by stage and rep",
"panels": [
{
"id": "deals-by-stage",
"title": "Deal Count by Stage",
"type": "bar",
"sql": "SELECT \"Stage\", COUNT(*) as count FROM v_deal GROUP BY \"Stage\" ORDER BY count DESC",
"mapping": { "xAxis": "Stage", "yAxis": ["count"] },
"size": "half"
},
{
"id": "revenue-trend",
"title": "Revenue Over Time",
"type": "area",
"sql": "SELECT DATE_TRUNC('month', created_at) as month, SUM(\"Amount\"::NUMERIC) as revenue FROM v_deal GROUP BY month ORDER BY month",
"mapping": { "xAxis": "month", "yAxis": ["revenue"] },
"size": "half"
},
{
"id": "stage-distribution",
"title": "Stage Distribution",
"type": "pie",
"sql": "SELECT \"Stage\", COUNT(*) as count FROM v_deal GROUP BY \"Stage\"",
"mapping": { "nameKey": "Stage", "valueKey": "count" },
"size": "third"
}
],
"filters": [
{
"id": "date-range",
"type": "dateRange",
"label": "Date Range",
"column": "created_at"
},
{
"id": "assigned-to",
"type": "select",
"label": "Assigned To",
"sql": "SELECT DISTINCT \"Assigned To\" as value FROM v_deal WHERE \"Assigned To\" IS NOT NULL",
"column": "Assigned To"
}
]
}
```
**Chart types supported:** `bar`, `line`, `area`, `pie`, `donut`, `radar`, `radialBar`, `scatter`, `funnel`.
**Panel sizes:** `full`, `half`, `third` (CSS grid layout).
**Filter types:** `dateRange`, `select`, `multiSelect`, `number`.
When filters are active, they inject `WHERE` clauses into each panel's SQL before execution.
---
## Phase 1: Recharts + Report Viewer Component
**Add Recharts dependency:**
- [apps/web/package.json](apps/web/package.json) -- add `recharts` to dependencies
**New components** (all in `apps/web/app/components/charts/`):
- `**chart-panel.tsx**` -- Wrapper that takes a panel config + data rows, renders the correct Recharts chart (Bar, Line, Area, Pie, etc.). Uses the app's CSS variable palette for theming (`--color-accent`, `--color-text`, `--color-border`). One component, switch on `panel.type`.
- `**filter-bar.tsx**` -- Horizontal filter strip. Reads filter configs from the report, fetches options for `select` type filters via SQL, renders date pickers / dropdowns. Emits active filter state upward.
- `**report-viewer.tsx**` -- Full report dashboard. Fetches report config (from file or prop), iterates panels, executes each panel's SQL (with filter injection), renders `ChartPanel` components in a CSS grid (`size` controls column span). Includes a header with title/description and the filter bar.
---
## Phase 2: Workspace Integration
**Extend content types** in [apps/web/app/workspace/page.tsx](apps/web/app/workspace/page.tsx):
- Add `report` to the `ContentState` union: `{ kind: "report"; reportPath: string; filename: string }`
- Add `report` case to `ContentRenderer` that renders `ReportViewer`
- In `loadContent`, detect `.report.json` files and load as `report` kind
**Extend knowledge tree** in [apps/web/app/components/workspace/knowledge-tree.tsx](apps/web/app/components/workspace/knowledge-tree.tsx):
- Add `"report"` to the `TreeNode.type` union
- Add report icon (bar chart icon) to `NodeTypeIcon`
**Extend tree API** in [apps/web/app/api/workspace/tree/route.ts](apps/web/app/api/workspace/tree/route.ts):
- Detect `.report.json` files and assign them `type: "report"` in the tree
**New API route** -- `apps/web/app/api/workspace/reports/execute/route.ts`:
- POST `{ sql: string, filters?: FilterState }` -- injects filter WHERE clauses into SQL, executes via `duckdbQuery`, returns rows
- This is separate from the generic query endpoint because it handles filter injection safely
---
## Phase 3: Chat Artifact (Inline Reports)
**Report block convention in agent text:**
The agent emits a fenced code block with language `report-json` containing the report JSON. Example in the streamed text:
````
Here's your pipeline analysis:
```report-json
{"version":1,"title":"Deals by Stage","panels":[...]}
````
The data shows most deals are in the Discovery stage.
`````
**Modify [apps/web/app/components/chat-message.tsx](apps/web/app/components/chat-message.tsx):**
- In `groupParts`, detect text segments containing ````report-json ... ```` blocks
- Split text around report blocks into `text` and `report-artifact` segments
- New segment type: `{ type: "report-artifact"; config: ReportConfig }`
**New component** -- `apps/web/app/components/charts/report-card.tsx`:
- Compact inline report card rendered inside chat bubbles
- Shows report title + a subset of panels (auto-sized to fit chat width)
- "Open in Workspace" button that saves to filesystem + navigates
- "Pin" action to persist an ephemeral chat report as a `.report.json` file
---
## Phase 4: Sidebar Reports Section
**Modify [apps/web/app/components/sidebar.tsx](apps/web/app/components/sidebar.tsx):**
- Add "Reports" as a new `SidebarSection` (between Workspace and Memories)
- Fetch report list from `/api/workspace/tree` (filter for `type: "report"` nodes)
- Each report links to `/workspace?path=reports/{name}.report.json`
- Show chart icon + report title
---
## Phase 5: Dench Skill Updates
**Modify [skills/dench/SKILL.md](skills/dench/SKILL.md):**
- Add Section 13: Report Generation
- Document the `.report.json` format with full schema reference
- Provide example reports for common CRM analytics:
- Pipeline funnel (deals by stage)
- Revenue trend over time
- Lead source breakdown
- Activity/task completion rates
- Contact growth over time
- Instruct the agent to:
- Create reports in `reports/` directory
- Use the existing `v_{object}` PIVOT views in SQL queries
- Include relevant filters (date range, assignee, status)
- Emit `report-json` blocks in chat for inline rendering
- Choose appropriate chart types for the data shape
- Add a "Post-Report Checklist" matching the existing post-mutation pattern
---
## File Summary
| Action | File |
| ------ | -------------------------------------------------------------------- |
| Modify | `apps/web/package.json` (add recharts) |
| Create | `apps/web/app/components/charts/chart-panel.tsx` |
| Create | `apps/web/app/components/charts/report-viewer.tsx` |
| Create | `apps/web/app/components/charts/report-card.tsx` |
| Create | `apps/web/app/components/charts/filter-bar.tsx` |
| Create | `apps/web/app/api/workspace/reports/execute/route.ts` |
| Modify | `apps/web/app/workspace/page.tsx` (add report content type) |
| Modify | `apps/web/app/components/chat-message.tsx` (detect report blocks) |
| Modify | `apps/web/app/components/sidebar.tsx` (Reports section) |
| Modify | `apps/web/app/components/workspace/knowledge-tree.tsx` (report node) |
| Modify | `apps/web/app/api/workspace/tree/route.ts` (detect .report.json) |
| Modify | `skills/dench/SKILL.md` (report generation instructions) |
`````

View File

@ -0,0 +1,282 @@
---
name: Sidebar File Manager
overview: Transform the workspace file tree sidebar (both on `/` home and `/workspace` pages) into a full-fledged file system manager with context menus, drag-and-drop file moves, create/delete/rename operations, system file locking, and live reactivity -- modeled after macOS Finder.
todos:
- id: api-system-files
content: Add isSystemFile() to lib/workspace.ts, extend safeResolvePath to support non-existent target paths
status: pending
- id: api-delete
content: Add DELETE handler to /api/workspace/file/route.ts with system file protection
status: pending
- id: api-rename
content: Create /api/workspace/rename/route.ts (POST) with validation and system file protection
status: pending
- id: api-move
content: Create /api/workspace/move/route.ts (POST) for drag-and-drop moves
status: pending
- id: api-mkdir
content: Create /api/workspace/mkdir/route.ts (POST) for creating new directories
status: pending
- id: api-copy
content: Create /api/workspace/copy/route.ts (POST) for duplicating files/folders
status: pending
- id: api-watch
content: Create /api/workspace/watch/route.ts SSE endpoint using chokidar for live file events
status: pending
- id: install-dndkit
content: Install @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities in apps/web
status: pending
- id: context-menu
content: "Build context-menu.tsx: portal-based right-click menu with Finder-like options, system file lock states"
status: pending
- id: inline-rename
content: "Build inline-rename.tsx: double-click/F2 to rename in-place with validation"
status: pending
- id: file-manager-tree
content: "Build file-manager-tree.tsx: unified DnD tree wrapping context menu, inline rename, drag-drop, keyboard shortcuts"
status: pending
- id: sse-hook
content: "Build useWorkspaceWatcher hook: SSE connection with debounced tree refetch and auto-reconnect"
status: pending
- id: integrate-workspace-sidebar
content: Replace KnowledgeTree with FileManagerTree in workspace-sidebar.tsx
status: pending
- id: integrate-home-sidebar
content: Replace WorkspaceTreeNode in sidebar.tsx with FileManagerTree (compact mode)
status: pending
- id: integrate-workspace-page
content: Wire workspace/page.tsx to useWorkspaceWatcher for live-reactive tree state
status: pending
- id: keyboard-shortcuts
content: Add keyboard navigation and file operation shortcuts to FileManagerTree
status: pending
isProject: false
---
# Full File System Manager Sidebar
## Current State
Two sidebar trees render workspace files read-only:
- **Home sidebar** (`[apps/web/app/components/sidebar.tsx](apps/web/app/components/sidebar.tsx)`): `WorkspaceSection` / `WorkspaceTreeNode` -- compact tree inside collapsible section
- **Workspace sidebar** (`[apps/web/app/components/workspace/workspace-sidebar.tsx](apps/web/app/components/workspace/workspace-sidebar.tsx)`): wraps `KnowledgeTree` from `[knowledge-tree.tsx](apps/web/app/components/workspace/knowledge-tree.tsx)`
Both fetch from `GET /api/workspace/tree` (`[apps/web/app/api/workspace/tree/route.ts](apps/web/app/api/workspace/tree/route.ts)`). File read/write exists at `/api/workspace/file` (`[apps/web/app/api/workspace/file/route.ts](apps/web/app/api/workspace/file/route.ts)`). No delete, rename, move, mkdir, or copy endpoints exist. No context menus, drag-and-drop, or live refresh.
## Architecture
```mermaid
flowchart TD
subgraph frontend [Frontend Components]
ContextMenu[ContextMenu Component]
FileTree[FileTree - Unified Tree with DnD]
InlineRename[Inline Rename Input]
NewFileDialog[New File/Folder Prompt]
end
subgraph api [API Routes - apps/web/app/api/workspace/]
TreeRoute[GET /tree]
FileRoute[GET+POST /file]
DeleteRoute[DELETE /file]
RenameRoute[POST /rename]
MoveRoute[POST /move]
MkdirRoute[POST /mkdir]
CopyRoute[POST /copy]
WatchRoute[GET /watch - SSE]
end
subgraph fsLib [lib/workspace.ts]
SafeResolve[safeResolvePath]
IsSystemFile[isSystemFile]
end
FileTree -->|right-click| ContextMenu
FileTree -->|drag-drop| MoveRoute
ContextMenu -->|"New File/Folder"| MkdirRoute
ContextMenu -->|Delete| DeleteRoute
ContextMenu -->|Rename| InlineRename
ContextMenu -->|Duplicate| CopyRoute
ContextMenu -->|"Move to..."| MoveRoute
InlineRename --> RenameRoute
NewFileDialog --> MkdirRoute
NewFileDialog --> FileRoute
DeleteRoute --> SafeResolve
RenameRoute --> SafeResolve
MoveRoute --> SafeResolve
CopyRoute --> SafeResolve
DeleteRoute --> IsSystemFile
RenameRoute --> IsSystemFile
MoveRoute --> IsSystemFile
WatchRoute -->|"SSE events"| FileTree
```
## 1. New Backend API Endpoints
Add to `[apps/web/app/api/workspace/](apps/web/app/api/workspace/)`:
`**DELETE /api/workspace/file**` - Delete a file or folder
- Body: `{ path: string }`
- Reject if `isSystemFile(path)` returns true
- Use `fs.rmSync(absPath, { recursive: true })` for folders
`**POST /api/workspace/rename**` - Rename a file or folder (new route file)
- Body: `{ path: string, newName: string }`
- Reject system files; validate newName (no slashes, no `.` prefix for non-system)
- Use `fs.renameSync(oldAbs, newAbs)` where newAbs replaces only the basename
`**POST /api/workspace/move**` - Move a file/folder to a new parent (new route file)
- Body: `{ sourcePath: string, destinationDir: string }`
- Reject system files; validate destination exists and is a directory
- Use `fs.renameSync(srcAbs, join(destAbs, basename(srcAbs)))`
`**POST /api/workspace/mkdir**` - Create a directory (new route file)
- Body: `{ path: string }`
- Use `fs.mkdirSync(absPath, { recursive: true })`
`**POST /api/workspace/copy**` - Duplicate a file or folder (new route file)
- Body: `{ path: string, destinationPath?: string }`
- If no destination, auto-name as `<name> copy.<ext>`
- Use `fs.cpSync` for recursive copy
`**GET /api/workspace/watch**` - SSE endpoint for live changes (new route file)
- Uses `chokidar` to watch the dench workspace root
- Streams SSE events: `{ type: "add"|"change"|"unlink"|"addDir"|"unlinkDir", path: string }`
- Client reconnects on close; debounce events (200ms)
### System File Protection
Add to `[apps/web/lib/workspace.ts](apps/web/lib/workspace.ts)`:
```typescript
const SYSTEM_FILE_PATTERNS = [
/^\.object\.yaml$/,
/^workspace\.duckdb/,
/^workspace_context\.yaml$/,
/\.wal$/,
/\.tmp$/,
];
export function isSystemFile(relativePath: string): boolean {
const basename = relativePath.split("/").pop() ?? "";
return SYSTEM_FILE_PATTERNS.some((p) => p.test(basename));
}
```
Extend `safeResolvePath` to optionally accept a `mustExist: false` flag (currently returns null for non-existent paths, but mkdir/create need paths that don't exist yet).
## 2. Context Menu Component
Create `[apps/web/app/components/workspace/context-menu.tsx](apps/web/app/components/workspace/context-menu.tsx)`:
- Pure CSS + React portal-based context menu (no library, matches the zero-dep approach)
- Positioned at cursor coordinates, clamped to viewport
- Closes on click-outside, Escape, or scroll
- Menu items with icons, keyboard shortcut hints, separators, and disabled states
**Menu structure** (mirrors Finder):
| For files | For folders | For empty area |
| ---------- | -------------------- | -------------- |
| Open | Open | New File |
| Rename | New File inside... | New Folder |
| Duplicate | New Folder inside... | Paste |
| Copy | Rename | --- |
| Move to... | Duplicate | --- |
| --- | Copy | --- |
| Get Info | Move to... | --- |
| --- | --- | --- |
| Delete | Delete | --- |
System files (`.object.yaml`, `workspace.duckdb`) show the same menu but all mutating actions are **disabled** with a lock icon and "System file" tooltip.
## 3. Drag-and-Drop for File Moves
Install `@dnd-kit/core` + `@dnd-kit/sortable` + `@dnd-kit/utilities` (lightweight, ~15KB gzipped, React 19 compatible).
Create `[apps/web/app/components/workspace/dnd-file-tree.tsx](apps/web/app/components/workspace/dnd-file-tree.tsx)`:
- Each tree node is both a **draggable** and a **droppable** (folders accept drops)
- Drag overlay shows a ghost of the file/folder name with icon
- Drop targets highlight with accent border when hovered
- On drop: call `POST /api/workspace/move` with `{ sourcePath, destinationDir }`
- System files: draggable is **disabled** (visual lock indicator)
- Folder auto-expand on hover during drag (300ms delay)
- Drop validation: prevent dropping a folder into itself or its children
## 4. Inline Rename
- Double-click or press Enter/F2 on a selected node to enter rename mode
- Replace the label `<span>` with a controlled `<input>` pre-filled with the current name
- Commit on Enter or blur; cancel on Escape
- Call `POST /api/workspace/rename` on commit
- Shake animation + red border on validation error (empty name, invalid chars, name collision)
## 5. Unified FileManagerTree Component
Refactor `[knowledge-tree.tsx](apps/web/app/components/workspace/knowledge-tree.tsx)` into a new `FileManagerTree` that wraps DnD + context menu + inline rename:
```
FileManagerTree (DndContext provider + SSE watcher)
└─ FileManagerNode (draggable + droppable + onContextMenu + double-click rename)
├─ ChevronIcon
├─ NodeIcon (+ lock badge for system files)
├─ InlineRenameInput | Label
└─ Children (recursive)
```
Both sidebars (`sidebar.tsx` WorkspaceSection and `workspace-sidebar.tsx`) will use this unified component, passing a `compact` prop to control spacing/features for the home sidebar (e.g., hide "Get Info", simpler context menu).
## 6. Live Reactivity (SSE)
Create a `useWorkspaceWatcher` hook in `[apps/web/app/hooks/use-workspace-watcher.ts](apps/web/app/hooks/use-workspace-watcher.ts)`:
- Opens an `EventSource` to `GET /api/workspace/watch`
- On any file event, debounces and refetches the tree from `/api/workspace/tree`
- Provides `tree`, `loading`, and `refresh()` to consumers
- Auto-reconnects with exponential backoff (1s, 2s, 4s, max 30s)
- Falls back to polling every 5s if SSE fails
Both sidebars and the workspace page will use this hook for shared, reactive tree state.
## 7. Keyboard Shortcuts
In the `FileManagerTree`, attach keyboard handlers on the focused tree container:
- **Delete / Backspace**: Delete selected item (with confirmation dialog)
- **Enter / F2**: Start inline rename
- **Cmd+C**: Copy path to clipboard
- **Cmd+D**: Duplicate
- **Cmd+N**: New file in current folder
- **Cmd+Shift+N**: New folder in current folder
- **Arrow keys**: Navigate tree up/down, expand/collapse with left/right
- **Space**: Quick Look / preview toggle (future)
## Files Changed
| File | Action |
| --------------------------------------------------------- | -------------------------------------------------------------- |
| `apps/web/package.json` | Add `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` |
| `apps/web/lib/workspace.ts` | Add `isSystemFile()`, extend `safeResolvePath` |
| `apps/web/app/api/workspace/file/route.ts` | Add `DELETE` handler |
| `apps/web/app/api/workspace/rename/route.ts` | New -- rename endpoint |
| `apps/web/app/api/workspace/move/route.ts` | New -- move endpoint |
| `apps/web/app/api/workspace/mkdir/route.ts` | New -- mkdir endpoint |
| `apps/web/app/api/workspace/copy/route.ts` | New -- copy endpoint |
| `apps/web/app/api/workspace/watch/route.ts` | New -- SSE file watcher |
| `apps/web/app/components/workspace/context-menu.tsx` | New -- right-click menu |
| `apps/web/app/components/workspace/file-manager-tree.tsx` | New -- unified DnD + context menu tree |
| `apps/web/app/components/workspace/inline-rename.tsx` | New -- inline rename input |
| `apps/web/app/hooks/use-workspace-watcher.ts` | New -- SSE watcher hook |
| `apps/web/app/components/workspace/knowledge-tree.tsx` | Refactor into file-manager-tree |
| `apps/web/app/components/workspace/workspace-sidebar.tsx` | Use new FileManagerTree |
| `apps/web/app/components/sidebar.tsx` | Use new FileManagerTree (compact mode) |
| `apps/web/app/workspace/page.tsx` | Use `useWorkspaceWatcher` hook |

View File

@ -14,6 +14,8 @@ export type WebSessionMeta = {
createdAt: number;
updatedAt: number;
messageCount: number;
/** When set, this session is scoped to a specific workspace file. */
filePath?: string;
};
function ensureDir() {
@ -24,7 +26,7 @@ function ensureDir() {
function readIndex(): WebSessionMeta[] {
ensureDir();
if (!existsSync(INDEX_FILE)) return [];
if (!existsSync(INDEX_FILE)) {return [];}
try {
return JSON.parse(readFileSync(INDEX_FILE, "utf-8"));
} catch {
@ -37,9 +39,18 @@ function writeIndex(sessions: WebSessionMeta[]) {
writeFileSync(INDEX_FILE, JSON.stringify(sessions, null, 2));
}
/** GET /api/web-sessions — list all web chat sessions */
export async function GET() {
const sessions = readIndex();
/** GET /api/web-sessions list web chat sessions.
* ?filePath=... returns only sessions scoped to that file.
* No filePath returns only global (non-file) sessions. */
export async function GET(req: Request) {
const url = new URL(req.url);
const filePath = url.searchParams.get("filePath");
const all = readIndex();
const sessions = filePath
? all.filter((s) => s.filePath === filePath)
: all.filter((s) => !s.filePath);
return Response.json({ sessions });
}
@ -53,6 +64,7 @@ export async function POST(req: Request) {
createdAt: Date.now(),
updatedAt: Date.now(),
messageCount: 0,
...(body.filePath ? { filePath: body.filePath } : {}),
};
const sessions = readIndex();

View File

@ -0,0 +1,77 @@
import { cpSync, existsSync, statSync } from "node:fs";
import { dirname, basename, extname, join } from "node:path";
import { safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* POST /api/workspace/copy
* Body: { path: string, destinationPath?: string }
*
* Duplicates a file or folder. If no destinationPath is provided,
* creates a copy next to the original with " copy" appended.
*/
export async function POST(req: Request) {
let body: { path?: string; destinationPath?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { path: relPath, destinationPath } = body;
if (!relPath || typeof relPath !== "string") {
return Response.json(
{ error: "Missing 'path' field" },
{ status: 400 },
);
}
const srcAbs = safeResolvePath(relPath);
if (!srcAbs) {
return Response.json(
{ error: "Source not found or path traversal rejected" },
{ status: 404 },
);
}
let destRelPath: string;
if (destinationPath && typeof destinationPath === "string") {
destRelPath = destinationPath;
} else {
// Auto-generate "name copy.ext" or "name copy" for folders
const name = basename(relPath);
const dir = dirname(relPath);
const ext = extname(name);
const stem = ext ? name.slice(0, -ext.length) : name;
const copyName = ext ? `${stem} copy${ext}` : `${stem} copy`;
destRelPath = dir === "." ? copyName : `${dir}/${copyName}`;
}
const destAbs = safeResolveNewPath(destRelPath);
if (!destAbs) {
return Response.json(
{ error: "Invalid destination path" },
{ status: 400 },
);
}
if (existsSync(destAbs)) {
return Response.json(
{ error: "Destination already exists" },
{ status: 409 },
);
}
try {
const isDir = statSync(srcAbs).isDirectory();
cpSync(srcAbs, destAbs, { recursive: isDir });
return Response.json({ ok: true, sourcePath: relPath, newPath: destRelPath });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "Copy failed" },
{ status: 500 },
);
}
}

View File

@ -1,6 +1,6 @@
import { writeFileSync, mkdirSync } from "node:fs";
import { writeFileSync, mkdirSync, rmSync, statSync } from "node:fs";
import { dirname } from "node:path";
import { readWorkspaceFile, safeResolvePath } from "@/lib/workspace";
import { readWorkspaceFile, safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@ -49,7 +49,8 @@ export async function POST(req: Request) {
);
}
const absPath = safeResolvePath(relPath);
// Use safeResolveNewPath (not safeResolvePath) because the file may not exist yet
const absPath = safeResolveNewPath(relPath);
if (!absPath) {
return Response.json(
{ error: "Invalid path or path traversal rejected" },
@ -68,3 +69,53 @@ export async function POST(req: Request) {
);
}
}
/**
* DELETE /api/workspace/file
* Body: { path: string }
*
* Deletes a file or folder from the dench workspace.
* System files (.object.yaml, workspace.duckdb, etc.) are protected.
*/
export async function DELETE(req: Request) {
let body: { path?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { path: relPath } = body;
if (!relPath || typeof relPath !== "string") {
return Response.json(
{ error: "Missing 'path' field" },
{ status: 400 },
);
}
if (isSystemFile(relPath)) {
return Response.json(
{ error: "Cannot delete system file" },
{ status: 403 },
);
}
const absPath = safeResolvePath(relPath);
if (!absPath) {
return Response.json(
{ error: "File not found or path traversal rejected" },
{ status: 404 },
);
}
try {
const stat = statSync(absPath);
rmSync(absPath, { recursive: stat.isDirectory() });
return Response.json({ ok: true, path: relPath });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "Delete failed" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,53 @@
import { mkdirSync, existsSync } from "node:fs";
import { safeResolveNewPath } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* POST /api/workspace/mkdir
* Body: { path: string }
*
* Creates a new directory in the dench workspace.
*/
export async function POST(req: Request) {
let body: { path?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { path: relPath } = body;
if (!relPath || typeof relPath !== "string") {
return Response.json(
{ error: "Missing 'path' field" },
{ status: 400 },
);
}
const absPath = safeResolveNewPath(relPath);
if (!absPath) {
return Response.json(
{ error: "Invalid path or path traversal rejected" },
{ status: 400 },
);
}
if (existsSync(absPath)) {
return Response.json(
{ error: "Directory already exists" },
{ status: 409 },
);
}
try {
mkdirSync(absPath, { recursive: true });
return Response.json({ ok: true, path: relPath });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "mkdir failed" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,93 @@
import { renameSync, existsSync, statSync } from "node:fs";
import { join, basename } from "node:path";
import { safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* POST /api/workspace/move
* Body: { sourcePath: string, destinationDir: string }
*
* Moves a file or folder into a different directory.
* System files are protected from moving.
*/
export async function POST(req: Request) {
let body: { sourcePath?: string; destinationDir?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { sourcePath, destinationDir } = body;
if (!sourcePath || typeof sourcePath !== "string" || !destinationDir || typeof destinationDir !== "string") {
return Response.json(
{ error: "Missing 'sourcePath' and 'destinationDir' fields" },
{ status: 400 },
);
}
if (isSystemFile(sourcePath)) {
return Response.json(
{ error: "Cannot move system file" },
{ status: 403 },
);
}
const srcAbs = safeResolvePath(sourcePath);
if (!srcAbs) {
return Response.json(
{ error: "Source not found or path traversal rejected" },
{ status: 404 },
);
}
const destDirAbs = safeResolvePath(destinationDir);
if (!destDirAbs) {
return Response.json(
{ error: "Destination not found or path traversal rejected" },
{ status: 404 },
);
}
// Destination must be a directory
if (!statSync(destDirAbs).isDirectory()) {
return Response.json(
{ error: "Destination is not a directory" },
{ status: 400 },
);
}
// Prevent moving a folder into itself or its children
const srcAbsNorm = srcAbs + "/";
if (destDirAbs.startsWith(srcAbsNorm) || destDirAbs === srcAbs) {
return Response.json(
{ error: "Cannot move a folder into itself" },
{ status: 400 },
);
}
const itemName = basename(srcAbs);
const destAbs = join(destDirAbs, itemName);
if (existsSync(destAbs)) {
return Response.json(
{ error: `'${itemName}' already exists in destination` },
{ status: 409 },
);
}
// Build new relative path
const newRelPath = destinationDir === "." ? itemName : `${destinationDir}/${itemName}`;
try {
renameSync(srcAbs, destAbs);
return Response.json({ ok: true, oldPath: sourcePath, newPath: newRelPath });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "Move failed" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,82 @@
import { duckdbQuery, duckdbPath, duckdbExec } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* PATCH /api/workspace/objects/[name]/display-field
* Set which field is used as the display label for entries of this object.
* Body: { displayField: string }
*/
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ name: string }> },
) {
const { name } = await params;
if (!duckdbPath()) {
return Response.json(
{ error: "DuckDB database not found" },
{ status: 404 },
);
}
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
return Response.json(
{ error: "Invalid object name" },
{ status: 400 },
);
}
const body = await req.json();
const { displayField } = body;
if (typeof displayField !== "string" || !displayField.trim()) {
return Response.json(
{ error: "displayField must be a non-empty string" },
{ status: 400 },
);
}
// Ensure display_field column exists
duckdbExec(
"ALTER TABLE objects ADD COLUMN IF NOT EXISTS display_field VARCHAR",
);
// Verify the object exists
const objects = duckdbQuery<{ id: string }>(
`SELECT id FROM objects WHERE name = '${name}' LIMIT 1`,
);
if (objects.length === 0) {
return Response.json(
{ error: `Object '${name}' not found` },
{ status: 404 },
);
}
// Verify the field exists on this object
const escapedField = displayField.replace(/'/g, "''");
const fieldCheck = duckdbQuery<{ id: string }>(
`SELECT id FROM fields WHERE object_id = '${objects[0].id}' AND name = '${escapedField}' LIMIT 1`,
);
if (fieldCheck.length === 0) {
return Response.json(
{ error: `Field '${displayField}' not found on object '${name}'` },
{ status: 400 },
);
}
// Update the display_field
const success = duckdbExec(
`UPDATE objects SET display_field = '${escapedField}', updated_at = now() WHERE name = '${name}'`,
);
if (!success) {
return Response.json(
{ error: "Failed to update display field" },
{ status: 500 },
);
}
return Response.json({ ok: true, displayField });
}

View File

@ -1,4 +1,4 @@
import { duckdbQuery, duckdbPath } from "@/lib/workspace";
import { duckdbQuery, duckdbPath, duckdbExec, parseRelationValue } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@ -9,6 +9,7 @@ type ObjectRow = {
description?: string;
icon?: string;
default_view?: string;
display_field?: string;
immutable?: boolean;
created_at?: string;
updated_at?: string;
@ -44,18 +45,25 @@ type EavRow = {
value: string | null;
};
// --- Schema migration (idempotent, runs once per process) ---
let schemaMigrated = false;
function ensureDisplayFieldColumn() {
if (schemaMigrated) {return;}
duckdbExec(
"ALTER TABLE objects ADD COLUMN IF NOT EXISTS display_field VARCHAR",
);
schemaMigrated = true;
}
// --- Helpers ---
/**
* Pivot raw EAV rows into one object per entry with field names as keys.
* Input: [{ entry_id, field_name, value }, ...]
* Output: [{ entry_id, "Full Name": "Sarah", "Email": "sarah@..." }, ...]
*/
function pivotEavRows(
rows: EavRow[],
): Record<string, unknown>[] {
const grouped = new Map<
string,
Record<string, unknown>
>();
function pivotEavRows(rows: EavRow[]): Record<string, unknown>[] {
const grouped = new Map<string, Record<string, unknown>>();
for (const row of rows) {
let entry = grouped.get(row.entry_id);
@ -75,6 +83,232 @@ function pivotEavRows(
return Array.from(grouped.values());
}
function tryParseJson(value: unknown): unknown {
if (typeof value !== "string") {return value;}
try {
return JSON.parse(value);
} catch {
return value;
}
}
/** SQL-escape a string (double single-quotes). */
function sqlEscape(s: string): string {
return s.replace(/'/g, "''");
}
/**
* Determine the display field for an object.
* Priority: explicit display_field > heuristic (name/title) > first text field > first field.
*/
function resolveDisplayField(
obj: ObjectRow,
objFields: FieldRow[],
): string {
if (obj.display_field) {return obj.display_field;}
// Heuristic: look for name/title fields
const nameField = objFields.find(
(f) =>
/\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
);
if (nameField) {return nameField.name;}
// Fallback: first text field
const textField = objFields.find((f) => f.type === "text");
if (textField) {return textField.name;}
// Ultimate fallback: first field
return objFields[0]?.name ?? "id";
}
/**
* Resolve relation field values to human-readable display labels.
* Returns: { fieldName: { entryId: displayLabel } }
*/
function resolveRelationLabels(
fields: FieldRow[],
entries: Record<string, unknown>[],
): {
labels: Record<string, Record<string, string>>;
relatedObjectNames: Record<string, string>;
} {
const labels: Record<string, Record<string, string>> = {};
const relatedObjectNames: Record<string, string> = {};
const relationFields = fields.filter(
(f) => f.type === "relation" && f.related_object_id,
);
for (const rf of relationFields) {
const relatedObjs = duckdbQuery<ObjectRow>(
`SELECT * FROM objects WHERE id = '${sqlEscape(rf.related_object_id!)}' LIMIT 1`,
);
if (relatedObjs.length === 0) {continue;}
const relObj = relatedObjs[0];
relatedObjectNames[rf.name] = relObj.name;
// Get related object's fields for display field resolution
const relFields = duckdbQuery<FieldRow>(
`SELECT * FROM fields WHERE object_id = '${sqlEscape(relObj.id)}' ORDER BY sort_order`,
);
const displayFieldName = resolveDisplayField(relObj, relFields);
// Collect all referenced entry IDs from our entries
const entryIds = new Set<string>();
for (const entry of entries) {
const val = entry[rf.name];
if (val == null || val === "") {continue;}
for (const id of parseRelationValue(String(val))) {
entryIds.add(id);
}
}
if (entryIds.size === 0) {
labels[rf.name] = {};
continue;
}
// Query display values for the referenced entries
const idList = Array.from(entryIds)
.map((id) => `'${sqlEscape(id)}'`)
.join(",");
const displayRows = duckdbQuery<{
entry_id: string;
value: string;
}>(
`SELECT e.id as entry_id, ef.value
FROM entries e
JOIN entry_fields ef ON ef.entry_id = e.id
JOIN fields f ON f.id = ef.field_id
WHERE e.id IN (${idList})
AND f.object_id = '${sqlEscape(relObj.id)}'
AND f.name = '${sqlEscape(displayFieldName)}'`,
);
const labelMap: Record<string, string> = {};
for (const row of displayRows) {
labelMap[row.entry_id] = row.value || row.entry_id;
}
// Fill in any IDs that didn't get a display label
for (const id of entryIds) {
if (!labelMap[id]) {labelMap[id] = id;}
}
labels[rf.name] = labelMap;
}
return { labels, relatedObjectNames };
}
type ReverseRelation = {
fieldName: string;
sourceObjectName: string;
sourceObjectId: string;
displayField: string;
entries: Record<string, Array<{ id: string; label: string }>>;
};
/**
* Find reverse relations: other objects with relation fields pointing TO this object.
* For each, resolve the display labels and group by target entry ID.
*/
function findReverseRelations(objectId: string): ReverseRelation[] {
// Find all relation fields in other objects that reference this object
const reverseFields = duckdbQuery<
FieldRow & { source_object_id: string; source_object_name: string }
>(
`SELECT f.*, f.object_id as source_object_id, o.name as source_object_name
FROM fields f
JOIN objects o ON o.id = f.object_id
WHERE f.type = 'relation'
AND f.related_object_id = '${sqlEscape(objectId)}'`,
);
if (reverseFields.length === 0) {return [];}
const result: ReverseRelation[] = [];
for (const rrf of reverseFields) {
// Get source object and its fields
const sourceObjs = duckdbQuery<ObjectRow>(
`SELECT * FROM objects WHERE id = '${sqlEscape(rrf.source_object_id)}' LIMIT 1`,
);
if (sourceObjs.length === 0) {continue;}
const sourceFields = duckdbQuery<FieldRow>(
`SELECT * FROM fields WHERE object_id = '${sqlEscape(rrf.source_object_id)}' ORDER BY sort_order`,
);
const displayFieldName = resolveDisplayField(sourceObjs[0], sourceFields);
// Fetch all source entries that have this relation field set
const refRows = duckdbQuery<{
source_entry_id: string;
target_value: string;
}>(
`SELECT ef.entry_id as source_entry_id, ef.value as target_value
FROM entry_fields ef
WHERE ef.field_id = '${sqlEscape(rrf.id)}'
AND ef.value IS NOT NULL
AND ef.value != ''`,
);
if (refRows.length === 0) {continue;}
// Get display labels for the source entries
const sourceEntryIds = [
...new Set(refRows.map((r) => r.source_entry_id)),
];
const idList = sourceEntryIds
.map((id) => `'${sqlEscape(id)}'`)
.join(",");
const displayRows = duckdbQuery<{
entry_id: string;
value: string;
}>(
`SELECT ef.entry_id, ef.value
FROM entry_fields ef
JOIN fields f ON f.id = ef.field_id
WHERE ef.entry_id IN (${idList})
AND f.name = '${sqlEscape(displayFieldName)}'
AND f.object_id = '${sqlEscape(rrf.source_object_id)}'`,
);
const displayMap: Record<string, string> = {};
for (const row of displayRows) {
displayMap[row.entry_id] = row.value || row.entry_id;
}
// Build: target_entry_id -> [{id, label}]
const entriesMap: Record<
string,
Array<{ id: string; label: string }>
> = {};
for (const row of refRows) {
const targetIds = parseRelationValue(row.target_value);
for (const targetId of targetIds) {
if (!entriesMap[targetId]) {entriesMap[targetId] = [];}
entriesMap[targetId].push({
id: row.source_entry_id,
label: displayMap[row.source_entry_id] || row.source_entry_id,
});
}
}
result.push({
fieldName: rrf.name,
sourceObjectName: rrf.source_object_name,
sourceObjectId: rrf.source_object_id,
displayField: displayFieldName,
entries: entriesMap,
});
}
return result;
}
// --- Route handler ---
export async function GET(
_req: Request,
{ params }: { params: Promise<{ name: string }> },
@ -96,6 +330,9 @@ export async function GET(
);
}
// Ensure display_field column exists (idempotent migration)
ensureDisplayFieldColumn();
// Fetch object metadata
const objects = duckdbQuery<ObjectRow>(
`SELECT * FROM objects WHERE name = '${name}' LIMIT 1`,
@ -123,7 +360,6 @@ export async function GET(
// Try the PIVOT view first, then fall back to raw EAV query + client-side pivot
let entries: Record<string, unknown>[] = [];
// Attempt PIVOT view
const pivotEntries = duckdbQuery(
`SELECT * FROM v_${name} ORDER BY created_at DESC LIMIT 200`,
);
@ -131,7 +367,6 @@ export async function GET(
if (pivotEntries.length > 0) {
entries = pivotEntries;
} else {
// Fallback: raw EAV query, then pivot in JS
const rawRows = duckdbQuery<EavRow>(
`SELECT e.id as entry_id, e.created_at, e.updated_at,
f.name as field_name, ef.value
@ -153,19 +388,30 @@ export async function GET(
enum_colors: f.enum_colors ? tryParseJson(f.enum_colors) : undefined,
}));
// Resolve relation field values to human-readable display labels
const { labels: relationLabels, relatedObjectNames } =
resolveRelationLabels(fields, entries);
// Enrich fields with related object names for frontend display
const enrichedFields = parsedFields.map((f) => ({
...f,
related_object_name:
f.type === "relation" ? relatedObjectNames[f.name] : undefined,
}));
// Find reverse relations (other objects linking TO this one)
const reverseRelations = findReverseRelations(obj.id);
// Compute the effective display field for this object
const effectiveDisplayField = resolveDisplayField(obj, fields);
return Response.json({
object: obj,
fields: parsedFields,
fields: enrichedFields,
statuses,
entries,
relationLabels,
reverseRelations,
effectiveDisplayField,
});
}
function tryParseJson(value: unknown): unknown {
if (typeof value !== "string") {return value;}
try {
return JSON.parse(value);
} catch {
return value;
}
}

View File

@ -0,0 +1,84 @@
import { renameSync, existsSync } from "node:fs";
import { join, dirname, basename } from "node:path";
import { safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* POST /api/workspace/rename
* Body: { path: string, newName: string }
*
* Renames a file or folder within the same directory.
* System files are protected from renaming.
*/
export async function POST(req: Request) {
let body: { path?: string; newName?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { path: relPath, newName } = body;
if (!relPath || typeof relPath !== "string" || !newName || typeof newName !== "string") {
return Response.json(
{ error: "Missing 'path' and 'newName' fields" },
{ status: 400 },
);
}
if (isSystemFile(relPath)) {
return Response.json(
{ error: "Cannot rename system file" },
{ status: 403 },
);
}
// Validate newName: no slashes, no empty, no traversal
if (newName.includes("/") || newName.includes("\\") || newName.trim() === "") {
return Response.json(
{ error: "Invalid file name" },
{ status: 400 },
);
}
const absPath = safeResolvePath(relPath);
if (!absPath) {
return Response.json(
{ error: "Source not found or path traversal rejected" },
{ status: 404 },
);
}
const parentDir = dirname(absPath);
const newAbsPath = join(parentDir, newName);
// Ensure the new path stays within workspace
const parentRel = dirname(relPath);
const newRelPath = parentRel === "." ? newName : `${parentRel}/${newName}`;
const validated = safeResolveNewPath(newRelPath);
if (!validated) {
return Response.json(
{ error: "Invalid destination path" },
{ status: 400 },
);
}
if (existsSync(newAbsPath)) {
return Response.json(
{ error: `A file named '${newName}' already exists` },
{ status: 409 },
);
}
try {
renameSync(absPath, newAbsPath);
return Response.json({ ok: true, oldPath: relPath, newPath: newRelPath });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "Rename failed" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,106 @@
import { resolveDenchRoot } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* GET /api/workspace/watch
*
* Server-Sent Events endpoint that watches the dench workspace for file changes.
* Sends events: { type: "add"|"change"|"unlink"|"addDir"|"unlinkDir", path: string }
* Falls back gracefully if chokidar is unavailable.
*/
export async function GET() {
const root = resolveDenchRoot();
if (!root) {
return new Response("Workspace not found", { status: 404 });
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// Send initial heartbeat so the client knows the connection is alive
controller.enqueue(encoder.encode("event: connected\ndata: {}\n\n"));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let watcher: any = null;
let closed = false;
// Debounce: batch rapid events into a single "refresh" signal
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
function sendEvent(type: string, filePath: string) {
if (closed) {return;}
if (debounceTimer) {clearTimeout(debounceTimer);}
debounceTimer = setTimeout(() => {
if (closed) {return;}
try {
const data = JSON.stringify({ type, path: filePath });
controller.enqueue(encoder.encode(`event: change\ndata: ${data}\n\n`));
} catch {
// Stream may have been closed
}
}, 200);
}
// Keep-alive heartbeat every 30s to prevent proxy/timeout disconnects
const heartbeat = setInterval(() => {
if (closed) {return;}
try {
controller.enqueue(encoder.encode(": heartbeat\n\n"));
} catch {
// Ignore if closed
}
}, 30_000);
try {
// Dynamic import so the route still compiles if chokidar is missing
const chokidar = await import("chokidar");
watcher = chokidar.watch(root, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 },
ignored: [
/(^|[\\/])node_modules([\\/]|$)/,
/\.duckdb\.wal$/,
/\.duckdb\.tmp$/,
],
depth: 10,
});
watcher.on("all", (eventType: string, filePath: string) => {
// Make path relative to workspace root
const rel = filePath.startsWith(root)
? filePath.slice(root.length + 1)
: filePath;
sendEvent(eventType, rel);
});
} catch {
// chokidar not available, send a fallback event and close
controller.enqueue(
encoder.encode("event: error\ndata: {\"error\":\"File watching unavailable\"}\n\n"),
);
}
// Cleanup when the client disconnects
// The cancel callback is invoked by the runtime when the response is aborted
const originalCancel = stream.cancel?.bind(stream);
stream.cancel = async (reason) => {
closed = true;
clearInterval(heartbeat);
if (debounceTimer) {clearTimeout(debounceTimer);}
if (watcher) {await watcher.close();}
if (originalCancel) {return originalCancel(reason);}
};
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

View File

@ -0,0 +1,670 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { ChatMessage } from "./chat-message";
const transport = new DefaultChatTransport({ api: "/api/chat" });
/** Imperative handle for parent-driven session control (main page). */
export type ChatPanelHandle = {
loadSession: (sessionId: string) => Promise<void>;
newSession: () => Promise<void>;
};
export type FileContext = {
path: string;
filename: string;
};
type FileScopedSession = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
};
type ChatPanelProps = {
/** When set, scopes sessions to this file and prepends content as context. */
fileContext?: FileContext;
/** Compact mode for workspace sidebar (smaller UI, built-in session tabs). */
compact?: boolean;
/** Called when file content may have changed after agent edits. */
onFileChanged?: (newContent: string) => void;
/** Called when active session changes (for external sidebar highlighting). */
onActiveSessionChange?: (sessionId: string | null) => void;
/** Called when session list needs refresh (for external sidebar). */
onSessionsChange?: () => void;
};
export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
function ChatPanel(
{
fileContext,
compact,
onFileChanged,
onActiveSessionChange,
onSessionsChange,
},
ref,
) {
const { messages, sendMessage, status, stop, error, setMessages } =
useChat({ transport });
const [input, setInput] = useState("");
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
null,
);
const [loadingSession, setLoadingSession] = useState(false);
const [startingNewSession, setStartingNewSession] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Track persisted messages to avoid double-saves
const savedMessageIdsRef = useRef<Set<string>>(new Set());
// Set when /new or + triggers a new session
const newSessionPendingRef = useRef(false);
// Whether the next message should include file context
const isFirstFileMessageRef = useRef(true);
// File-scoped session list (compact mode only)
const [fileSessions, setFileSessions] = useState<FileScopedSession[]>(
[],
);
const filePath = fileContext?.path ?? null;
const isStreaming = status === "streaming" || status === "submitted";
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// ── File-scoped sessions ──
const fetchFileSessions = useCallback(async () => {
if (!filePath) {return;}
try {
const res = await fetch(
`/api/web-sessions?filePath=${encodeURIComponent(filePath)}`,
);
const data = await res.json();
setFileSessions(data.sessions || []);
} catch {
// ignore
}
}, [filePath]);
useEffect(() => {
if (filePath) {fetchFileSessions();}
}, [filePath, fetchFileSessions]);
// Reset chat state when the active file changes
useEffect(() => {
if (!filePath) {return;}
stop();
setCurrentSessionId(null);
onActiveSessionChange?.(null);
setMessages([]);
savedMessageIdsRef.current.clear();
isFirstFileMessageRef.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable setters
}, [filePath]);
// ── Session persistence ──
const saveMessages = useCallback(
async (
sessionId: string,
msgs: Array<{
id: string;
role: string;
content: string;
parts?: unknown[];
}>,
title?: string,
) => {
const toSave = msgs.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
...(m.parts ? { parts: m.parts } : {}),
timestamp: new Date().toISOString(),
}));
try {
await fetch(
`/api/web-sessions/${sessionId}/messages`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: toSave,
title,
}),
},
);
for (const m of msgs)
{savedMessageIdsRef.current.add(m.id);}
onSessionsChange?.();
if (filePath) {fetchFileSessions();}
} catch (err) {
console.error("Failed to save messages:", err);
}
},
[onSessionsChange, filePath, fetchFileSessions],
);
const createSession = useCallback(
async (title: string): Promise<string> => {
const body: Record<string, string> = { title };
if (filePath) {body.filePath = filePath;}
const res = await fetch("/api/web-sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
return data.session.id;
},
[filePath],
);
/** Extract plain text from a UIMessage */
const getMessageText = useCallback(
(msg: (typeof messages)[number]): string => {
return (
msg.parts
?.filter(
(
p,
): p is {
type: "text";
text: string;
} => p.type === "text",
)
.map((p) => p.text)
.join("\n") ?? ""
);
},
[],
);
// Persist unsaved messages when streaming finishes + live-reload file
const prevStatusRef = useRef(status);
useEffect(() => {
const wasStreaming =
prevStatusRef.current === "streaming" ||
prevStatusRef.current === "submitted";
const isNowReady = status === "ready";
if (wasStreaming && isNowReady && currentSessionId) {
const unsaved = messages.filter(
(m) => !savedMessageIdsRef.current.has(m.id),
);
if (unsaved.length > 0) {
const toSave = unsaved.map((m) => ({
id: m.id,
role: m.role,
content: getMessageText(m),
parts: m.parts,
}));
saveMessages(currentSessionId, toSave);
}
// Re-fetch file content for live reload after agent edits
if (filePath && onFileChanged) {
fetch(
`/api/workspace/file?path=${encodeURIComponent(filePath)}`,
)
.then((r) => r.json())
.then((data) => {
if (data.content) {onFileChanged(data.content);}
})
.catch(() => {});
}
}
prevStatusRef.current = status;
}, [
status,
messages,
currentSessionId,
saveMessages,
getMessageText,
filePath,
onFileChanged,
]);
// ── Actions ──
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isStreaming) {return;}
const userText = input.trim();
setInput("");
if (userText.toLowerCase() === "/new") {
handleNewSession();
return;
}
// Create session if none
let sessionId = currentSessionId;
if (!sessionId) {
const title =
userText.length > 60
? userText.slice(0, 60) + "..."
: userText;
sessionId = await createSession(title);
setCurrentSessionId(sessionId);
onActiveSessionChange?.(sessionId);
onSessionsChange?.();
if (filePath) {fetchFileSessions();}
if (newSessionPendingRef.current) {
newSessionPendingRef.current = false;
const newMsgId = `system-new-${Date.now()}`;
await saveMessages(sessionId, [
{
id: newMsgId,
role: "user",
content: "/new",
parts: [{ type: "text", text: "/new" }],
},
]);
}
}
// Prepend file path context for the first message in a file-scoped session
let messageText = userText;
if (fileContext && isFirstFileMessageRef.current) {
messageText = `[Context: workspace file '${fileContext.path}']\n\n${userText}`;
isFirstFileMessageRef.current = false;
}
sendMessage({ text: messageText });
};
const handleSessionSelect = useCallback(
async (sessionId: string) => {
if (sessionId === currentSessionId) {return;}
setLoadingSession(true);
setCurrentSessionId(sessionId);
onActiveSessionChange?.(sessionId);
savedMessageIdsRef.current.clear();
isFirstFileMessageRef.current = false; // loaded session has context
try {
const response = await fetch(
`/api/web-sessions/${sessionId}`,
);
if (!response.ok)
{throw new Error("Failed to load session");}
const data = await response.json();
const sessionMessages: Array<{
id: string;
role: "user" | "assistant";
content: string;
parts?: Array<Record<string, unknown>>;
}> = data.messages || [];
const uiMessages = sessionMessages.map((msg) => {
savedMessageIdsRef.current.add(msg.id);
return {
id: msg.id,
role: msg.role,
parts: (msg.parts ?? [
{
type: "text" as const,
text: msg.content,
},
]) as UIMessage["parts"],
};
});
setMessages(uiMessages);
} catch (err) {
console.error("Error loading session:", err);
} finally {
setLoadingSession(false);
}
},
[currentSessionId, setMessages, onActiveSessionChange],
);
const handleNewSession = useCallback(async () => {
setCurrentSessionId(null);
onActiveSessionChange?.(null);
setMessages([]);
savedMessageIdsRef.current.clear();
isFirstFileMessageRef.current = true;
newSessionPendingRef.current = true;
// Only send /new to backend for non-file sessions (main chat)
if (!filePath) {
setStartingNewSession(true);
try {
await fetch("/api/new-session", { method: "POST" });
} catch (err) {
console.error("Failed to send /new:", err);
} finally {
setStartingNewSession(false);
}
}
}, [setMessages, onActiveSessionChange, filePath]);
// Expose imperative handle for parent-driven session management
useImperativeHandle(
ref,
() => ({
loadSession: handleSessionSelect,
newSession: handleNewSession,
}),
[handleSessionSelect, handleNewSession],
);
// ── Status label ──
const statusLabel = startingNewSession
? "Starting new session..."
: loadingSession
? "Loading session..."
: status === "ready"
? "Ready"
: status === "submitted"
? "Thinking..."
: status === "streaming"
? "Streaming..."
: status === "error"
? "Error"
: status;
// ── Render ──
return (
<div className="flex flex-col h-full">
{/* Header */}
<header
className={`${compact ? "px-3 py-2" : "px-6 py-3"} border-b flex items-center justify-between flex-shrink-0`}
style={{
borderColor: "var(--color-border)",
background: "var(--color-surface)",
}}
>
<div className="min-w-0 flex-1">
{compact && fileContext ? (
<>
<h2
className="text-xs font-semibold truncate"
style={{
color: "var(--color-text)",
}}
>
Chat: {fileContext.filename}
</h2>
<p
className="text-[10px]"
style={{
color: "var(--color-text-muted)",
}}
>
{statusLabel}
</p>
</>
) : (
<>
<h2 className="text-sm font-semibold">
{currentSessionId
? "Chat Session"
: "New Chat"}
</h2>
<p
className="text-xs"
style={{
color: "var(--color-text-muted)",
}}
>
{statusLabel}
</p>
</>
)}
</div>
<div className="flex gap-1 flex-shrink-0">
{compact && (
<button
type="button"
onClick={() => handleNewSession()}
className="p-1 rounded transition-colors"
style={{ color: "var(--color-text-muted)" }}
title="New chat"
onMouseEnter={(e) => {
(
e.currentTarget as HTMLElement
).style.background =
"var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(
e.currentTarget as HTMLElement
).style.background = "transparent";
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
</button>
)}
{isStreaming && (
<button
type="button"
onClick={() => stop()}
className={`${compact ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs"} rounded-md transition-colors`}
style={{
background: "var(--color-border)",
color: "var(--color-text)",
}}
>
Stop
</button>
)}
</div>
</header>
{/* File-scoped session tabs (compact mode) */}
{compact && fileContext && fileSessions.length > 0 && (
<div
className="px-2 py-1.5 border-b flex gap-1 overflow-x-auto flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
{fileSessions.slice(0, 10).map((s) => (
<button
key={s.id}
type="button"
onClick={() => handleSessionSelect(s.id)}
className="px-2 py-0.5 text-[10px] rounded-md whitespace-nowrap transition-colors flex-shrink-0"
style={{
background:
s.id === currentSessionId
? "var(--color-accent)"
: "var(--color-surface)",
color:
s.id === currentSessionId
? "white"
: "var(--color-text-muted)",
border: `1px solid ${s.id === currentSessionId ? "var(--color-accent)" : "var(--color-border)"}`,
}}
>
{s.title.length > 25
? s.title.slice(0, 25) + "..."
: s.title}
</button>
))}
</div>
)}
{/* Messages */}
<div
className={`flex-1 overflow-y-auto ${compact ? "px-3" : "px-6"}`}
>
{loadingSession ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div
className="w-6 h-6 border-2 rounded-full animate-spin mx-auto mb-3"
style={{
borderColor:
"var(--color-border)",
borderTopColor:
"var(--color-accent)",
}}
/>
<p
className="text-xs"
style={{
color: "var(--color-text-muted)",
}}
>
Loading session...
</p>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
{compact ? (
<p
className="text-sm"
style={{
color: "var(--color-text-muted)",
}}
>
Ask about this file
</p>
) : (
<>
<p className="text-6xl mb-4">
🦞
</p>
<h3 className="text-lg font-semibold mb-1">
OpenClaw Chat
</h3>
<p
className="text-sm"
style={{
color: "var(--color-text-muted)",
}}
>
Send a message to start a
conversation with your
agent.
</p>
</>
)}
</div>
</div>
) : (
<div
className={`${compact ? "" : "max-w-3xl mx-auto"} py-3`}
>
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
/>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Error display */}
{error && (
<div className="px-3 py-1.5 bg-red-900/20 border-t border-red-800/30 flex-shrink-0">
<p className="text-xs text-red-400">
Error: {error.message}
</p>
</div>
)}
{/* Input */}
<div
className={`${compact ? "px-3 py-2" : "px-6 py-4"} border-t flex-shrink-0`}
style={{
borderColor: "var(--color-border)",
background: "var(--color-surface)",
}}
>
<form
onSubmit={handleSubmit}
className={`${compact ? "" : "max-w-3xl mx-auto"} flex gap-2`}
>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={
compact && fileContext
? `Ask about ${fileContext.filename}...`
: "Message OpenClaw..."
}
disabled={
isStreaming ||
loadingSession ||
startingNewSession
}
className={`flex-1 ${compact ? "px-3 py-2 text-xs rounded-lg" : "px-4 py-3 text-sm rounded-xl"} border focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)] focus:border-transparent disabled:opacity-50`}
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
/>
<button
type="submit"
disabled={
!input.trim() ||
isStreaming ||
loadingSession ||
startingNewSession
}
className={`${compact ? "px-3 py-2 text-xs rounded-lg" : "px-5 py-3 text-sm rounded-xl"} font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed`}
style={{
background: "var(--color-accent)",
color: "white",
}}
>
{isStreaming ? (
<div
className={`${compact ? "w-3 h-3" : "w-5 h-5"} border-2 border-white/30 border-t-white rounded-full animate-spin`}
/>
) : (
"Send"
)}
</button>
</form>
</div>
</div>
);
},
);

View File

@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import { FileManagerTree } from "./workspace/file-manager-tree";
// --- Types ---
@ -209,150 +210,15 @@ function MemoriesSection({
);
}
// --- Workspace Section ---
// --- Workspace Section (uses FileManagerTree in compact mode) ---
function WorkspaceTreeNode({
node,
depth,
expanded,
onToggle,
}: {
node: TreeNode;
depth: number;
expanded: Set<string>;
onToggle: (path: string) => void;
}) {
const hasChildren = node.children && node.children.length > 0;
const isExpandable = hasChildren || node.type === "folder" || node.type === "object";
const isOpen = expanded.has(node.path);
const iconColor =
node.type === "object"
? "var(--color-accent)"
: node.type === "document"
? "#60a5fa"
: node.type === "database"
? "#c084fc"
: node.type === "report"
? "#22c55e"
: "var(--color-text-muted)";
return (
<div>
<div
className="flex items-center gap-1.5 py-1 px-2 rounded-md text-sm cursor-pointer transition-colors hover:bg-[var(--color-surface-hover)]"
style={{ paddingLeft: `${depth * 14 + 8}px`, color: "var(--color-text-muted)" }}
onClick={() => {
if (isExpandable) {onToggle(node.path);}
// Navigate to workspace page for actionable items
if (node.type === "object" || node.type === "document" || node.type === "file" || node.type === "database" || node.type === "report") {
window.location.href = `/workspace?path=${encodeURIComponent(node.path)}`;
}
}}
>
{/* Chevron */}
<span className="w-3.5 flex-shrink-0 flex items-center justify-center" style={{ opacity: isExpandable ? 1 : 0 }}>
{isExpandable && (
<svg
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: isOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 150ms" }}
>
<path d="m9 18 6-6-6-6" />
</svg>
)}
</span>
{/* Icon */}
<span className="flex-shrink-0" style={{ color: iconColor }}>
{node.type === "object" ? (
node.defaultView === "kanban" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="6" height="14" x="2" y="5" rx="1" /><rect width="6" height="10" x="9" y="5" rx="1" /><rect width="6" height="16" x="16" y="3" rx="1" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
</svg>
)
) : node.type === "document" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
</svg>
) : node.type === "folder" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
) : node.type === "database" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
<path d="M3 12A9 3 0 0 0 21 12" />
</svg>
) : node.type === "report" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
)}
</span>
{/* Name */}
<span className="truncate flex-1 text-xs">{node.name.replace(/\.md$/, "")}</span>
{/* Type badge for objects */}
{node.type === "object" && (
<span
className="text-[9px] px-1 py-0 rounded flex-shrink-0"
style={{ background: "rgba(232,93,58,0.15)", color: "var(--color-accent)" }}
>
{node.defaultView === "kanban" ? "board" : "table"}
</span>
)}
</div>
{isOpen && hasChildren && (
<div>
{node.children!.map((child) => (
<WorkspaceTreeNode
key={child.path}
node={child}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
);
}
function WorkspaceSection({ tree }: { tree: TreeNode[] }) {
const [expanded, setExpanded] = useState<Set<string>>(() => {
// Auto-expand first level
const initial = new Set<string>();
for (const node of tree) {
if (node.children && node.children.length > 0) {
initial.add(node.path);
}
function WorkspaceSection({ tree, onRefresh }: { tree: TreeNode[]; onRefresh: () => void }) {
const handleSelect = useCallback((node: TreeNode) => {
// Navigate to workspace page for actionable items
if (node.type === "object" || node.type === "document" || node.type === "file" || node.type === "database" || node.type === "report") {
window.location.href = `/workspace?path=${encodeURIComponent(node.path)}`;
}
return initial;
});
const toggle = (path: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(path)) {next.delete(path);}
else {next.add(path);}
return next;
});
};
}, []);
if (tree.length === 0) {
return (
@ -364,15 +230,13 @@ function WorkspaceSection({ tree }: { tree: TreeNode[] }) {
return (
<div className="space-y-0.5">
{tree.map((node) => (
<WorkspaceTreeNode
key={node.path}
node={node}
depth={0}
expanded={expanded}
onToggle={toggle}
/>
))}
<FileManagerTree
tree={tree}
activePath={null}
onSelect={handleSelect}
onRefresh={onRefresh}
compact
/>
{/* Full workspace link */}
<a
@ -523,6 +387,16 @@ export function Sidebar({
load();
}, [refreshKey]);
const refreshWorkspace = useCallback(async () => {
try {
const res = await fetch("/api/workspace/tree");
const data = await res.json();
setWorkspaceTree(data.tree ?? []);
} catch {
// ignore
}
}, []);
return (
<aside className="w-72 h-screen flex flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] overflow-hidden">
{/* Header with New Chat button */}
@ -569,7 +443,7 @@ export function Sidebar({
onToggle={() => toggleSection("workspace")}
/>
{openSections.has("workspace") && (
<WorkspaceSection tree={workspaceTree} />
<WorkspaceSection tree={workspaceTree} onRefresh={refreshWorkspace} />
)}
</div>
)}

View File

@ -0,0 +1,253 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
// --- Types ---
export type ContextMenuAction =
| "open"
| "newFile"
| "newFolder"
| "rename"
| "duplicate"
| "copy"
| "paste"
| "moveTo"
| "getInfo"
| "delete";
export type ContextMenuItem = {
action: ContextMenuAction;
label: string;
shortcut?: string;
icon?: React.ReactNode;
disabled?: boolean;
danger?: boolean;
separator?: false;
} | {
separator: true;
};
export type ContextMenuTarget =
| { kind: "file"; path: string; name: string; isSystem: boolean }
| { kind: "folder"; path: string; name: string; isSystem: boolean }
| { kind: "empty" };
// --- Menu item definitions per target kind ---
function getMenuItems(target: ContextMenuTarget): ContextMenuItem[] {
const isSystem = target.kind !== "empty" && target.isSystem;
if (target.kind === "file") {
return [
{ action: "open", label: "Open" },
{ separator: true },
{ action: "rename", label: "Rename", shortcut: "Enter", disabled: isSystem },
{ action: "duplicate", label: "Duplicate", shortcut: "\u2318D", disabled: isSystem },
{ action: "copy", label: "Copy Path", shortcut: "\u2318C" },
{ separator: true },
{ action: "getInfo", label: "Get Info", shortcut: "\u2318I" },
{ separator: true },
{ action: "delete", label: "Move to Trash", shortcut: "\u2318\u232B", disabled: isSystem, danger: true },
];
}
if (target.kind === "folder") {
return [
{ action: "open", label: "Open" },
{ separator: true },
{ action: "newFile", label: "New File", shortcut: "\u2318N", disabled: isSystem },
{ action: "newFolder", label: "New Folder", shortcut: "\u21E7\u2318N", disabled: isSystem },
{ separator: true },
{ action: "rename", label: "Rename", shortcut: "Enter", disabled: isSystem },
{ action: "duplicate", label: "Duplicate", shortcut: "\u2318D", disabled: isSystem },
{ action: "copy", label: "Copy Path", shortcut: "\u2318C" },
{ separator: true },
{ action: "getInfo", label: "Get Info", shortcut: "\u2318I" },
{ separator: true },
{ action: "delete", label: "Move to Trash", shortcut: "\u2318\u232B", disabled: isSystem, danger: true },
];
}
// Empty area
return [
{ action: "newFile", label: "New File", shortcut: "\u2318N" },
{ action: "newFolder", label: "New Folder", shortcut: "\u21E7\u2318N" },
{ separator: true },
{ action: "paste", label: "Paste", shortcut: "\u2318V", disabled: true },
];
}
// --- Lock icon for system files ---
function LockIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.5 }}>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
// --- Context Menu Component ---
type ContextMenuProps = {
x: number;
y: number;
target: ContextMenuTarget;
onAction: (action: ContextMenuAction) => void;
onClose: () => void;
};
export function ContextMenu({ x, y, target, onAction, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const items = getMenuItems(target);
const isSystem = target.kind !== "empty" && target.isSystem;
// Clamp position to viewport
const clampedPos = useRef({ x, y });
useEffect(() => {
const el = menuRef.current;
if (!el) {return;}
const rect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let cx = x;
let cy = y;
if (cx + rect.width > vw - 8) {cx = vw - rect.width - 8;}
if (cy + rect.height > vh - 8) {cy = vh - rect.height - 8;}
if (cx < 8) {cx = 8;}
if (cy < 8) {cy = 8;}
clampedPos.current = { x: cx, y: cy };
el.style.left = `${cx}px`;
el.style.top = `${cy}px`;
}, [x, y]);
// Close on click-outside, escape, scroll
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {onClose();}
}
function handleScroll() {
onClose();
}
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("scroll", handleScroll, true);
};
}, [onClose]);
const handleItemClick = useCallback(
(action: ContextMenuAction, disabled?: boolean) => {
if (disabled) {return;}
onAction(action);
onClose();
},
[onAction, onClose],
);
return createPortal(
<div
ref={menuRef}
className="fixed z-[9999] min-w-[200px] py-1 rounded-lg shadow-xl border"
style={{
left: x,
top: y,
background: "var(--color-surface)",
borderColor: "var(--color-border)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
animation: "contextMenuFadeIn 100ms ease-out",
}}
role="menu"
>
{/* System file badge */}
{isSystem && (
<div
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px]"
style={{ color: "var(--color-text-muted)" }}
>
<LockIcon />
<span>System file (locked)</span>
</div>
)}
{items.map((item, i) => {
if ("separator" in item && item.separator) {
return (
<div
key={`sep-${i}`}
className="my-1 mx-2 border-t"
style={{ borderColor: "var(--color-border)" }}
/>
);
}
const menuItem = item as Exclude<ContextMenuItem, { separator: true }>;
const isDisabled = menuItem.disabled;
return (
<button
key={menuItem.action}
type="button"
role="menuitem"
disabled={isDisabled}
className="w-full flex items-center gap-2 px-3 py-1.5 text-[13px] text-left transition-colors"
style={{
color: isDisabled
? "var(--color-text-muted)"
: menuItem.danger
? "#ef4444"
: "var(--color-text)",
opacity: isDisabled ? 0.5 : 1,
cursor: isDisabled ? "default" : "pointer",
}}
onMouseEnter={(e) => {
if (!isDisabled) {
(e.currentTarget as HTMLElement).style.background = menuItem.danger
? "rgba(239, 68, 68, 0.1)"
: "var(--color-surface-hover)";
}
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
onClick={() => handleItemClick(menuItem.action, isDisabled)}
>
{menuItem.icon}
<span className="flex-1">{menuItem.label}</span>
{isDisabled && isSystem && <LockIcon />}
{menuItem.shortcut && (
<span
className="text-[11px] ml-4"
style={{ color: "var(--color-text-muted)" }}
>
{menuItem.shortcut}
</span>
)}
</button>
);
})}
{/* Global animation style */}
<style>{`
@keyframes contextMenuFadeIn {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
</div>,
document.body,
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,103 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
type InlineRenameProps = {
currentName: string;
onCommit: (newName: string) => void;
onCancel: () => void;
};
/**
* Inline text input that replaces a tree node label for renaming.
* Commits on Enter or blur, cancels on Escape.
* Shows a shake animation on validation error.
*/
export function InlineRename({ currentName, onCommit, onCancel }: InlineRenameProps) {
const [value, setValue] = useState(currentName);
const [error, setError] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-focus and select the name (without extension)
useEffect(() => {
const input = inputRef.current;
if (!input) {return;}
input.focus();
const dotIndex = currentName.lastIndexOf(".");
if (dotIndex > 0) {
input.setSelectionRange(0, dotIndex);
} else {
input.select();
}
}, [currentName]);
const validate = useCallback(
(name: string): boolean => {
const trimmed = name.trim();
if (!trimmed) {return false;}
if (trimmed.includes("/") || trimmed.includes("\\")) {return false;}
return true;
},
[],
);
const handleCommit = useCallback(() => {
const trimmed = value.trim();
if (!validate(trimmed)) {
setError(true);
setTimeout(() => setError(false), 500);
return;
}
if (trimmed === currentName) {
onCancel();
return;
}
onCommit(trimmed);
}, [value, currentName, validate, onCommit, onCancel]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
handleCommit();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[handleCommit, onCancel],
);
return (
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => {
setValue(e.target.value);
setError(false);
}}
onBlur={handleCommit}
onKeyDown={handleKeyDown}
className="flex-1 text-sm rounded px-1 py-0 outline-none min-w-0"
style={{
background: "var(--color-bg)",
color: "var(--color-text)",
border: error ? "1px solid #ef4444" : "1px solid var(--color-accent)",
animation: error ? "renameShake 300ms ease" : undefined,
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
);
}
/** Shake animation style (injected once globally via the FileManagerTree) */
export const RENAME_SHAKE_STYLE = `
@keyframes renameShake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-3px); }
40%, 80% { transform: translateX(3px); }
}
`;

View File

@ -8,6 +8,7 @@ type Field = {
type: string;
enum_values?: string[];
enum_colors?: string[];
related_object_name?: string;
};
type Status = {
@ -23,18 +24,38 @@ type ObjectKanbanProps = {
entries: Record<string, unknown>[];
statuses: Status[];
members?: Array<{ id: string; name: string }>;
relationLabels?: Record<string, Record<string, string>>;
};
// --- Helpers ---
function parseRelationValue(value: string | null | undefined): string[] {
if (!value) {return [];}
const trimmed = value.trim();
if (!trimmed) {return [];}
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {return parsed.map(String).filter(Boolean);}
} catch {
// not valid JSON
}
}
return [trimmed];
}
// --- Card component ---
function KanbanCard({
entry,
fields,
members,
relationLabels,
}: {
entry: Record<string, unknown>;
fields: Field[];
members?: Array<{ id: string; name: string }>;
relationLabels?: Record<string, Record<string, string>>;
}) {
// Show first 4 non-status fields
const displayFields = fields
@ -90,11 +111,16 @@ function KanbanCard({
const val = entry[field.name];
if (!val) {return null;}
// Resolve user fields
// Resolve display value based on field type
let displayVal = String(val);
if (field.type === "user") {
const member = members?.find((m) => m.id === displayVal);
if (member) {displayVal = member.name;}
} else if (field.type === "relation") {
const fieldLabels = relationLabels?.[field.name];
const ids = parseRelationValue(displayVal);
const labels = ids.map((id) => fieldLabels?.[id] ?? id);
displayVal = labels.join(", ");
}
return (
@ -104,10 +130,32 @@ function KanbanCard({
</span>
{field.type === "enum" ? (
<EnumBadgeMini
value={displayVal}
value={String(val)}
enumValues={field.enum_values}
enumColors={field.enum_colors}
/>
) : field.type === "relation" ? (
<span
className="truncate inline-flex items-center gap-0.5"
style={{ color: "#60a5fa" }}
>
<svg
width="8"
height="8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0"
style={{ opacity: 0.5 }}
>
<path d="M7 7h10v10" />
<path d="M7 17 17 7" />
</svg>
{displayVal}
</span>
) : (
<span
className="truncate"
@ -157,6 +205,7 @@ export function ObjectKanban({
entries,
statuses,
members,
relationLabels,
}: ObjectKanbanProps) {
// Find the grouping field: prefer a "Status" enum field, fallback to first enum
const groupField = useMemo(() => {
@ -283,6 +332,7 @@ export function ObjectKanban({
entry={entry}
fields={cardFields}
members={members}
relationLabels={relationLabels}
/>
))
)}
@ -322,6 +372,7 @@ export function ObjectKanban({
entry={entry}
fields={cardFields}
members={members}
relationLabels={relationLabels}
/>
))}
</div>

View File

@ -2,6 +2,8 @@
import { useState, useMemo } from "react";
// --- Types ---
type Field = {
id: string;
name: string;
@ -9,16 +11,47 @@ type Field = {
enum_values?: string[];
enum_colors?: string[];
enum_multiple?: boolean;
related_object_id?: string;
relationship_type?: string;
related_object_name?: string;
sort_order?: number;
};
type ReverseRelation = {
fieldName: string;
sourceObjectName: string;
sourceObjectId: string;
displayField: string;
entries: Record<string, Array<{ id: string; label: string }>>;
};
type ObjectTableProps = {
objectName: string;
fields: Field[];
entries: Record<string, unknown>[];
members?: Array<{ id: string; name: string }>;
relationLabels?: Record<string, Record<string, string>>;
reverseRelations?: ReverseRelation[];
onNavigateToObject?: (objectName: string) => void;
};
// --- Helpers ---
function parseRelationValue(value: string | null | undefined): string[] {
if (!value) {return [];}
const trimmed = value.trim();
if (!trimmed) {return [];}
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {return parsed.map(String).filter(Boolean);}
} catch {
// not valid JSON
}
}
return [trimmed];
}
// --- Sort helpers ---
type SortState = {
@ -111,14 +144,154 @@ function UserCell({
);
}
/** Inline link icon (small arrow) for relation chips. */
function LinkIcon() {
return (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0"
style={{ opacity: 0.5 }}
>
<path d="M7 7h10v10" />
<path d="M7 17 17 7" />
</svg>
);
}
/** A single relation chip showing a display label with an optional link icon. */
function RelationChip({
label,
objectName,
onNavigate,
}: {
label: string;
objectName?: string;
onNavigate?: (objectName: string) => void;
}) {
const handleClick = objectName && onNavigate
? (e: React.MouseEvent) => {
e.stopPropagation();
onNavigate(objectName);
}
: undefined;
return (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium ${handleClick ? "cursor-pointer" : ""}`}
style={{
background: "rgba(96, 165, 250, 0.1)",
color: "#60a5fa",
border: "1px solid rgba(96, 165, 250, 0.2)",
maxWidth: "200px",
}}
onClick={handleClick}
title={label}
>
<LinkIcon />
<span className="truncate">{label}</span>
</span>
);
}
/** Render a relation field cell with resolved display labels. */
function RelationCell({
value,
field,
relationLabels,
onNavigate,
}: {
value: unknown;
field: Field;
relationLabels?: Record<string, Record<string, string>>;
onNavigate?: (objectName: string) => void;
}) {
const fieldLabels = relationLabels?.[field.name];
const ids = parseRelationValue(String(value));
if (ids.length === 0) {
return (
<span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>
--
</span>
);
}
return (
<span className="flex items-center gap-1 flex-wrap">
{ids.map((id) => (
<RelationChip
key={id}
label={fieldLabels?.[id] ?? id}
objectName={field.related_object_name}
onNavigate={onNavigate}
/>
))}
</span>
);
}
/** Render a reverse relation cell (incoming links from another object). */
function ReverseRelationCell({
links,
sourceObjectName,
onNavigate,
}: {
links: Array<{ id: string; label: string }>;
sourceObjectName: string;
onNavigate?: (objectName: string) => void;
}) {
if (!links || links.length === 0) {
return (
<span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>
--
</span>
);
}
const displayLinks = links.slice(0, 5);
const overflow = links.length - displayLinks.length;
return (
<span className="flex items-center gap-1 flex-wrap">
{displayLinks.map((link) => (
<RelationChip
key={link.id}
label={link.label}
objectName={sourceObjectName}
onNavigate={onNavigate}
/>
))}
{overflow > 0 && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{ color: "var(--color-text-muted)" }}
>
+{overflow} more
</span>
)}
</span>
);
}
function CellValue({
value,
field,
members,
relationLabels,
onNavigate,
}: {
value: unknown;
field: Field;
members?: Array<{ id: string; name: string }>;
relationLabels?: Record<string, Record<string, string>>;
onNavigate?: (objectName: string) => void;
}) {
if (value === null || value === undefined || value === "") {
return (
@ -141,6 +314,15 @@ function CellValue({
return <BooleanCell value={value} />;
case "user":
return <UserCell value={value} members={members} />;
case "relation":
return (
<RelationCell
value={value}
field={field}
relationLabels={relationLabels}
onNavigate={onNavigate}
/>
);
case "email":
return (
<a
@ -167,6 +349,9 @@ export function ObjectTable({
fields,
entries,
members,
relationLabels,
reverseRelations,
onNavigateToObject,
}: ObjectTableProps) {
const [sort, setSort] = useState<SortState>(null);
@ -191,6 +376,14 @@ export function ObjectTable({
});
}, [entries, sort]);
// Filter out reverse relations with no actual data
const activeReverseRelations = useMemo(() => {
if (!reverseRelations) {return [];}
return reverseRelations.filter(
(rr) => Object.keys(rr.entries).length > 0,
);
}, [reverseRelations]);
if (entries.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-3">
@ -210,6 +403,7 @@ export function ObjectTable({
<table className="w-full text-sm" style={{ borderCollapse: "separate", borderSpacing: 0 }}>
<thead>
<tr>
{/* Regular field columns */}
{fields.map((field) => (
<th
key={field.id}
@ -226,6 +420,14 @@ export function ObjectTable({
>
<span className="flex items-center gap-1">
{field.name}
{field.type === "relation" && field.related_object_name && (
<span
className="text-[9px] font-normal normal-case tracking-normal opacity-60"
title={`Links to ${field.related_object_name}`}
>
({field.related_object_name})
</span>
)}
<SortIcon
active={sort?.column === field.name}
direction={sort?.column === field.name ? sort.direction : "asc"}
@ -233,6 +435,43 @@ export function ObjectTable({
</span>
</th>
))}
{/* Reverse relation columns */}
{activeReverseRelations.map((rr) => (
<th
key={`rev_${rr.sourceObjectName}_${rr.fieldName}`}
className="text-left px-3 py-2.5 font-medium text-xs uppercase tracking-wider whitespace-nowrap border-b"
style={{
color: "var(--color-text-muted)",
borderColor: "var(--color-border)",
background: "var(--color-surface)",
position: "sticky",
top: 0,
zIndex: 1,
}}
>
<span className="flex items-center gap-1.5">
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ opacity: 0.4 }}
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
<span className="capitalize">{rr.sourceObjectName}</span>
<span className="text-[9px] font-normal normal-case tracking-normal opacity-50">
via {rr.fieldName}
</span>
</span>
</th>
))}
</tr>
</thead>
<tbody>
@ -253,6 +492,7 @@ export function ObjectTable({
idx % 2 === 0 ? "transparent" : "var(--color-surface)";
}}
>
{/* Regular field cells */}
{fields.map((field) => (
<td
key={field.id}
@ -263,9 +503,30 @@ export function ObjectTable({
value={entry[field.name]}
field={field}
members={members}
relationLabels={relationLabels}
onNavigate={onNavigateToObject}
/>
</td>
))}
{/* Reverse relation cells */}
{activeReverseRelations.map((rr) => {
const entryId = String(entry.entry_id ?? "");
const links = rr.entries[entryId] ?? [];
return (
<td
key={`rev_${rr.sourceObjectName}_${rr.fieldName}`}
className="px-3 py-2 border-b whitespace-nowrap"
style={{ borderColor: "var(--color-border)" }}
>
<ReverseRelationCell
links={links}
sourceObjectName={rr.sourceObjectName}
onNavigate={onNavigateToObject}
/>
</td>
);
})}
</tr>
))}
</tbody>

View File

@ -1,11 +1,12 @@
"use client";
import { KnowledgeTree, type TreeNode } from "./knowledge-tree";
import { FileManagerTree, type TreeNode } from "./file-manager-tree";
type WorkspaceSidebarProps = {
tree: TreeNode[];
activePath: string | null;
onSelect: (node: TreeNode) => void;
onRefresh: () => void;
orgName?: string;
loading?: boolean;
};
@ -33,6 +34,7 @@ export function WorkspaceSidebar({
tree,
activePath,
onSelect,
onRefresh,
orgName,
loading,
}: WorkspaceSidebarProps) {
@ -84,10 +86,11 @@ export function WorkspaceSidebar({
/>
</div>
) : (
<KnowledgeTree
<FileManagerTree
tree={tree}
activePath={activePath}
onSelect={onSelect}
onRefresh={onRefresh}
/>
)}
</div>

View File

@ -0,0 +1,143 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
export type TreeNode = {
name: string;
path: string;
type: "object" | "document" | "folder" | "file" | "database" | "report";
icon?: string;
defaultView?: "table" | "kanban";
children?: TreeNode[];
};
/**
* Hook that fetches the workspace tree and subscribes to SSE file-change events
* for live reactivity. Falls back to polling if SSE is unavailable.
*/
export function useWorkspaceWatcher() {
const [tree, setTree] = useState<TreeNode[]>([]);
const [loading, setLoading] = useState(true);
const [exists, setExists] = useState(false);
const mountedRef = useRef(true);
const retryDelayRef = useRef(1000);
// Fetch the tree from the API
const fetchTree = useCallback(async () => {
try {
const res = await fetch("/api/workspace/tree");
const data = await res.json();
if (mountedRef.current) {
setTree(data.tree ?? []);
setExists(data.exists ?? false);
setLoading(false);
}
} catch {
if (mountedRef.current) {setLoading(false);}
}
}, []);
// Manual refresh for use after mutations
const refresh = useCallback(() => {
fetchTree();
}, [fetchTree]);
// Initial fetch
useEffect(() => {
mountedRef.current = true;
fetchTree();
return () => {
mountedRef.current = false;
};
}, [fetchTree]);
// SSE subscription with auto-reconnect and polling fallback
useEffect(() => {
let eventSource: EventSource | null = null;
let pollInterval: ReturnType<typeof setInterval> | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let alive = true;
// Debounce rapid SSE events into a single tree refetch
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
function debouncedRefetch() {
if (debounceTimer) {clearTimeout(debounceTimer);}
debounceTimer = setTimeout(() => {
if (alive) {fetchTree();}
}, 300);
}
function connectSSE() {
if (!alive) {return;}
try {
eventSource = new EventSource("/api/workspace/watch");
eventSource.addEventListener("connected", () => {
// Reset retry delay on successful connection
retryDelayRef.current = 1000;
// Stop polling fallback if it was active
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
});
eventSource.addEventListener("change", () => {
debouncedRefetch();
});
eventSource.addEventListener("error", () => {
// SSE errored -- close and schedule reconnect
eventSource?.close();
eventSource = null;
scheduleReconnect();
});
eventSource.onerror = () => {
eventSource?.close();
eventSource = null;
scheduleReconnect();
};
} catch {
// SSE not supported or network error -- fall back to polling
startPolling();
}
}
function scheduleReconnect() {
if (!alive) {return;}
// Start polling as fallback while we wait to reconnect
startPolling();
const delay = retryDelayRef.current;
retryDelayRef.current = Math.min(delay * 2, 30_000);
reconnectTimeout = setTimeout(() => {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
connectSSE();
}, delay);
}
function startPolling() {
if (pollInterval || !alive) {return;}
pollInterval = setInterval(() => {
if (alive) {fetchTree();}
}, 5000);
}
connectSSE();
return () => {
alive = false;
if (eventSource) {eventSource.close();}
if (pollInterval) {clearInterval(pollInterval);}
if (reconnectTimeout) {clearTimeout(reconnectTimeout);}
if (debounceTimer) {clearTimeout(debounceTimer);}
};
}, [fetchTree]);
return { tree, loading, exists, refresh };
}

View File

@ -1,337 +1,45 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useCallback, useEffect, useRef, useState } from "react";
import { ChatMessage } from "./components/chat-message";
import { useCallback, useRef, useState } from "react";
import { ChatPanel, type ChatPanelHandle } from "./components/chat-panel";
import { Sidebar } from "./components/sidebar";
const transport = new DefaultChatTransport({ api: "/api/chat" });
export default function Home() {
const { messages, sendMessage, status, stop, error, setMessages } = useChat({ transport });
const [input, setInput] = useState("");
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [loadingSession, setLoadingSession] = useState(false);
const [startingNewSession, setStartingNewSession] = useState(false);
const chatRef = useRef<ChatPanelHandle>(null);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Track which messages have already been persisted to avoid double-saves
const savedMessageIdsRef = useRef<Set<string>>(new Set());
// Set when /new or + explicitly triggers a new session, so we can record
// the /new command as the first entry when the session is actually created.
const newSessionPendingRef = useRef(false);
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const isStreaming = status === "streaming" || status === "submitted";
const refreshSidebar = useCallback(() => {
setSidebarRefreshKey((k) => k + 1);
}, []);
/** Persist messages to the web session's .jsonl file.
* Saves the full `parts` array (reasoning, tool calls, output, text)
* alongside a plain-text `content` field for backward compat / sidebar. */
const saveMessages = useCallback(
async (
sessionId: string,
msgs: Array<{
id: string;
role: string;
content: string;
parts?: unknown[];
}>,
title?: string,
) => {
const toSave = msgs.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
// Persist full UIMessage parts so reasoning + tool calls survive reload
...(m.parts ? { parts: m.parts } : {}),
timestamp: new Date().toISOString(),
}));
try {
await fetch(`/api/web-sessions/${sessionId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: toSave, title }),
});
for (const m of msgs) {savedMessageIdsRef.current.add(m.id);}
refreshSidebar();
} catch (err) {
console.error("Failed to save messages:", err);
}
},
[refreshSidebar],
);
/** Create a new web chat session and return its ID */
const createSession = useCallback(async (title: string): Promise<string> => {
const res = await fetch("/api/web-sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
const data = await res.json();
return data.session.id;
}, []);
/** Extract plain text from a UIMessage */
const getMessageText = useCallback(
(msg: (typeof messages)[number]): string => {
return (
msg.parts
?.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n") ?? ""
);
const handleSessionSelect = useCallback(
(sessionId: string) => {
chatRef.current?.loadSession(sessionId);
},
[],
);
// When streaming finishes, persist all unsaved messages (user + assistant).
// This is the single save-point for chat messages — handleSubmit only saves
// the synthetic /new marker; actual user/assistant messages are saved here
// so their ids match what useChat tracks (avoids duplicate-id issues).
const prevStatusRef = useRef(status);
useEffect(() => {
const wasStreaming =
prevStatusRef.current === "streaming" || prevStatusRef.current === "submitted";
const isNowReady = status === "ready";
const handleNewSession = useCallback(() => {
chatRef.current?.newSession();
}, []);
if (wasStreaming && isNowReady && currentSessionId) {
const unsaved = messages.filter((m) => !savedMessageIdsRef.current.has(m.id));
if (unsaved.length > 0) {
const toSave = unsaved.map((m) => ({
id: m.id,
role: m.role,
content: getMessageText(m),
parts: m.parts,
}));
saveMessages(currentSessionId, toSave);
}
}
prevStatusRef.current = status;
}, [status, messages, currentSessionId, saveMessages, getMessageText]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isStreaming) {return;}
const userText = input.trim();
setInput("");
// "/new" triggers a new session (same as clicking the + button)
if (userText.toLowerCase() === "/new") {
handleNewSession();
return;
}
// Create a session if we don't have one yet
let sessionId = currentSessionId;
if (!sessionId) {
const title = userText.length > 60 ? userText.slice(0, 60) + "..." : userText;
sessionId = await createSession(title);
setCurrentSessionId(sessionId);
refreshSidebar();
// If this session was triggered by /new or +, record it as the first entry
if (newSessionPendingRef.current) {
newSessionPendingRef.current = false;
const newMsgId = `system-new-${Date.now()}`;
await saveMessages(sessionId, [
{
id: newMsgId,
role: "user",
content: "/new",
parts: [{ type: "text", text: "/new" }],
},
]);
}
}
// Don't save the user message eagerly here — the useEffect that fires
// when streaming finishes saves all unsaved messages (user + assistant)
// using useChat's own ids, which avoids duplicate entries.
// Send to agent
sendMessage({ text: userText });
};
/** Load a previous web chat session */
const handleSessionSelect = useCallback(
async (sessionId: string) => {
if (sessionId === currentSessionId) {return;}
setLoadingSession(true);
setCurrentSessionId(sessionId);
savedMessageIdsRef.current.clear();
try {
const response = await fetch(`/api/web-sessions/${sessionId}`);
if (!response.ok) {throw new Error("Failed to load session");}
const data = await response.json();
const sessionMessages: Array<{
id: string;
role: "user" | "assistant";
content: string;
parts?: Array<Record<string, unknown>>;
}> = data.messages || [];
// Convert to UIMessage format and mark all as saved.
// Restore from saved `parts` if available (preserves reasoning,
// tool calls, output), falling back to plain text for old sessions.
const uiMessages = sessionMessages.map((msg) => {
savedMessageIdsRef.current.add(msg.id);
return {
id: msg.id,
role: msg.role,
parts: msg.parts ?? [{ type: "text" as const, text: msg.content }],
};
});
setMessages(uiMessages);
} catch (err) {
console.error("Error loading session:", err);
} finally {
setLoadingSession(false);
}
},
[currentSessionId, setMessages],
);
/** Start a brand new session: clear UI, send /new to agent */
const handleNewSession = useCallback(async () => {
// Clear the UI immediately
setCurrentSessionId(null);
setMessages([]);
savedMessageIdsRef.current.clear();
// Mark that the next session should start with a /new entry
newSessionPendingRef.current = true;
// Send /new to the agent backend to start a fresh session
setStartingNewSession(true);
try {
await fetch("/api/new-session", { method: "POST" });
} catch (err) {
console.error("Failed to send /new:", err);
} finally {
setStartingNewSession(false);
}
}, [setMessages]);
const refreshSidebar = useCallback(() => {
setSidebarRefreshKey((k) => k + 1);
}, []);
return (
<div className="flex h-screen">
<Sidebar
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
activeSessionId={currentSessionId ?? undefined}
activeSessionId={activeSessionId ?? undefined}
refreshKey={sidebarRefreshKey}
/>
{/* Main chat area */}
<main className="flex-1 flex flex-col min-w-0">
{/* Chat header */}
<header className="px-6 py-3 border-b border-[var(--color-border)] flex items-center justify-between bg-[var(--color-surface)]">
<div>
<h2 className="text-sm font-semibold">
{currentSessionId ? "Chat Session" : "New Chat"}
</h2>
<p className="text-xs text-[var(--color-text-muted)]">
{startingNewSession
? "Starting new session..."
: loadingSession
? "Loading session..."
: status === "ready"
? "Ready"
: status === "submitted"
? "Thinking..."
: status === "streaming"
? "Streaming..."
: status === "error"
? "Error"
: status}
</p>
</div>
<div className="flex gap-2">
{isStreaming && (
<button
onClick={() => stop()}
className="px-3 py-1 text-xs rounded-md bg-[var(--color-border)] hover:bg-[var(--color-text-muted)] text-[var(--color-text)] transition-colors"
>
Stop
</button>
)}
</div>
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-6">
{loadingSession ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="w-8 h-8 border-2 border-[var(--color-border)] border-t-[var(--color-accent)] rounded-full animate-spin mx-auto mb-4" />
<p className="text-sm text-[var(--color-text-muted)]">Loading session...</p>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-6xl mb-4">🦞</p>
<h3 className="text-lg font-semibold mb-1">OpenClaw Chat</h3>
<p className="text-sm text-[var(--color-text-muted)]">
Send a message to start a conversation with your agent.
</p>
</div>
</div>
) : (
<div className="max-w-3xl mx-auto py-4">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Error display */}
{error && (
<div className="px-6 py-2 bg-red-900/20 border-t border-red-800/30">
<p className="text-sm text-red-400">Error: {error.message}</p>
</div>
)}
{/* Input */}
<div className="px-6 py-4 border-t border-[var(--color-border)] bg-[var(--color-surface)]">
<form onSubmit={handleSubmit} className="max-w-3xl mx-auto flex gap-3">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Message OpenClaw..."
disabled={isStreaming || loadingSession || startingNewSession}
className="flex-1 px-4 py-3 bg-[var(--color-bg)] border border-[var(--color-border)] rounded-xl text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)] focus:border-transparent disabled:opacity-50 text-sm"
/>
<button
type="submit"
disabled={!input.trim() || isStreaming || loadingSession || startingNewSession}
className="px-5 py-3 bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] text-white rounded-xl font-medium text-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{isStreaming ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
"Send"
)}
</button>
</form>
</div>
<ChatPanel
ref={chatRef}
onActiveSessionChange={setActiveSessionId}
onSessionsChange={refreshSidebar}
/>
</main>
</div>
);

View File

@ -1,9 +1,10 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { WorkspaceSidebar } from "../components/workspace/workspace-sidebar";
import { type TreeNode } from "../components/workspace/knowledge-tree";
import { type TreeNode } from "../components/workspace/file-manager-tree";
import { useWorkspaceWatcher } from "../hooks/use-workspace-watcher";
import { ObjectTable } from "../components/workspace/object-table";
import { ObjectKanban } from "../components/workspace/object-kanban";
import { DocumentView } from "../components/workspace/document-view";
@ -12,6 +13,7 @@ import { DatabaseViewer } from "../components/workspace/database-viewer";
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
import { EmptyState } from "../components/workspace/empty-state";
import { ReportViewer } from "../components/charts/report-viewer";
import { ChatPanel } from "../components/chat-panel";
// --- Types ---
@ -21,6 +23,14 @@ type WorkspaceContext = {
members?: Array<{ id: string; name: string; email: string; role: string }>;
};
type ReverseRelation = {
fieldName: string;
sourceObjectName: string;
sourceObjectId: string;
displayField: string;
entries: Record<string, Array<{ id: string; label: string }>>;
};
type ObjectData = {
object: {
id: string;
@ -28,6 +38,7 @@ type ObjectData = {
description?: string;
icon?: string;
default_view?: string;
display_field?: string;
};
fields: Array<{
id: string;
@ -36,6 +47,9 @@ type ObjectData = {
enum_values?: string[];
enum_colors?: string[];
enum_multiple?: boolean;
related_object_id?: string;
relationship_type?: string;
related_object_name?: string;
sort_order?: number;
}>;
statuses: Array<{
@ -45,6 +59,9 @@ type ObjectData = {
sort_order?: number;
}>;
entries: Record<string, unknown>[];
relationLabels?: Record<string, Record<string, string>>;
reverseRelations?: ReverseRelation[];
effectiveDisplayField?: string;
};
type FileData = {
@ -91,47 +108,48 @@ export default function WorkspacePage() {
const searchParams = useSearchParams();
const initialPathHandled = useRef(false);
const [tree, setTree] = useState<TreeNode[]>([]);
// Live-reactive tree via SSE watcher
const { tree, loading: treeLoading, exists: workspaceExists, refresh: refreshTree } = useWorkspaceWatcher();
const [context, setContext] = useState<WorkspaceContext | null>(null);
const [activePath, setActivePath] = useState<string | null>(null);
const [content, setContent] = useState<ContentState>({ kind: "none" });
const [treeLoading, setTreeLoading] = useState(true);
const [workspaceExists, setWorkspaceExists] = useState(true);
const [showChatSidebar, setShowChatSidebar] = useState(true);
// Fetch tree and context on mount
// Derive file context for chat sidebar directly from activePath (stable across loading)
const fileContext = useMemo(() => {
if (!activePath) {return undefined;}
const filename = activePath.split("/").pop() || activePath;
return { path: activePath, filename };
}, [activePath]);
// Update content state when the agent edits the file (live reload)
const handleFileChanged = useCallback((newContent: string) => {
setContent((prev) => {
if (prev.kind === "document") {
return { ...prev, data: { ...prev.data, content: newContent } };
}
if (prev.kind === "file") {
return { ...prev, data: { ...prev.data, content: newContent } };
}
return prev;
});
}, []);
// Fetch workspace context on mount
useEffect(() => {
let cancelled = false;
async function load() {
setTreeLoading(true);
async function loadContext() {
try {
const [treeRes, ctxRes] = await Promise.all([
fetch("/api/workspace/tree"),
fetch("/api/workspace/context"),
]);
const treeData = await treeRes.json();
const ctxData = await ctxRes.json();
if (cancelled) {return;}
setTree(treeData.tree ?? []);
setWorkspaceExists(treeData.exists ?? false);
setContext(ctxData);
const res = await fetch("/api/workspace/context");
const data = await res.json();
if (!cancelled) {setContext(data);}
} catch {
if (!cancelled) {
setTree([]);
setWorkspaceExists(false);
}
} finally {
if (!cancelled) {setTreeLoading(false);}
// ignore
}
}
load();
return () => {
cancelled = true;
};
loadContext();
return () => { cancelled = true; };
}, []);
// Load content when path changes
@ -165,10 +183,8 @@ export default function WorkspacePage() {
title: node.name.replace(/\.md$/, ""),
});
} else if (node.type === "database") {
// Database files are handled entirely by the DatabaseViewer component
setContent({ kind: "database", dbPath: node.path, filename: node.name });
} else if (node.type === "report") {
// Report files are handled entirely by the ReportViewer component
setContent({ kind: "report", reportPath: node.path, filename: node.name });
} else if (node.type === "file") {
const res = await fetch(
@ -226,6 +242,42 @@ export default function WorkspacePage() {
[tree, loadContent],
);
// Navigate to an object by name (used by relation links)
const handleNavigateToObject = useCallback(
(objectName: string) => {
// Find the object node in the tree
function findObjectNode(nodes: TreeNode[]): TreeNode | null {
for (const node of nodes) {
if (node.type === "object" && objectNameFromPath(node.path) === objectName) {
return node;
}
if (node.children) {
const found = findObjectNode(node.children);
if (found) {return found;}
}
}
return null;
}
const node = findObjectNode(tree);
if (node) {loadContent(node);}
},
[tree, loadContent],
);
// Refresh the currently displayed object (e.g. after changing display field)
const refreshCurrentObject = useCallback(async () => {
if (content.kind !== "object") {return;}
const name = content.data.object.name;
try {
const res = await fetch(`/api/workspace/objects/${encodeURIComponent(name)}`);
if (!res.ok) {return;}
const data: ObjectData = await res.json();
setContent({ kind: "object", data });
} catch {
// ignore
}
}, [content]);
return (
<div className="flex h-screen" style={{ background: "var(--color-bg)" }}>
{/* Sidebar */}
@ -233,6 +285,7 @@ export default function WorkspacePage() {
tree={tree}
activePath={activePath}
onSelect={handleNodeSelect}
onRefresh={refreshTree}
orgName={context?.organization?.name}
loading={treeLoading}
/>
@ -242,25 +295,63 @@ export default function WorkspacePage() {
{/* Top bar with breadcrumbs */}
{activePath && (
<div
className="px-6 border-b flex-shrink-0"
className="px-6 border-b flex-shrink-0 flex items-center justify-between"
style={{ borderColor: "var(--color-border)" }}
>
<Breadcrumbs
path={activePath}
onNavigate={handleBreadcrumbNavigate}
/>
{/* Chat sidebar toggle */}
<button
type="button"
onClick={() => setShowChatSidebar((v) => !v)}
className="p-1.5 rounded-md transition-colors flex-shrink-0"
style={{
color: showChatSidebar ? "var(--color-accent)" : "var(--color-text-muted)",
background: showChatSidebar ? "rgba(232, 93, 58, 0.1)" : "transparent",
}}
title={showChatSidebar ? "Hide chat" : "Chat about this file"}
>
<svg width="16" height="16" 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>
</button>
</div>
)}
{/* Content area */}
<div className="flex-1 overflow-y-auto">
<ContentRenderer
content={content}
workspaceExists={workspaceExists}
tree={tree}
members={context?.members}
onNodeSelect={handleNodeSelect}
/>
{/* Content + Chat sidebar row */}
<div className="flex-1 flex min-h-0">
{/* Content area */}
<div className="flex-1 overflow-y-auto">
<ContentRenderer
content={content}
workspaceExists={workspaceExists}
tree={tree}
members={context?.members}
onNodeSelect={handleNodeSelect}
onNavigateToObject={handleNavigateToObject}
onRefreshObject={refreshCurrentObject}
/>
</div>
{/* Chat sidebar (file-scoped) */}
{fileContext && showChatSidebar && (
<aside
className="flex-shrink-0 border-l"
style={{
width: 380,
borderColor: "var(--color-border)",
background: "var(--color-bg)",
}}
>
<ChatPanel
compact
fileContext={fileContext}
onFileChanged={handleFileChanged}
/>
</aside>
)}
</div>
</main>
</div>
@ -275,12 +366,16 @@ function ContentRenderer({
tree,
members,
onNodeSelect,
onNavigateToObject,
onRefreshObject,
}: {
content: ContentState;
workspaceExists: boolean;
tree: TreeNode[];
members?: Array<{ id: string; name: string; email: string; role: string }>;
onNodeSelect: (node: TreeNode) => void;
onNavigateToObject: (objectName: string) => void;
onRefreshObject: () => void;
}) {
switch (content.kind) {
case "loading":
@ -298,65 +393,12 @@ function ContentRenderer({
case "object":
return (
<div className="p-6">
{/* Object header */}
<div className="mb-6">
<h1
className="text-2xl font-bold capitalize"
style={{ color: "var(--color-text)" }}
>
{content.data.object.name}
</h1>
{content.data.object.description && (
<p
className="text-sm mt-1"
style={{ color: "var(--color-text-muted)" }}
>
{content.data.object.description}
</p>
)}
<div className="flex items-center gap-3 mt-3">
<span
className="text-xs px-2 py-1 rounded-full"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
}}
>
{content.data.entries.length} entries
</span>
<span
className="text-xs px-2 py-1 rounded-full"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
}}
>
{content.data.fields.length} fields
</span>
</div>
</div>
{/* Table or Kanban */}
{content.data.object.default_view === "kanban" ? (
<ObjectKanban
objectName={content.data.object.name}
fields={content.data.fields}
entries={content.data.entries}
statuses={content.data.statuses}
members={members}
/>
) : (
<ObjectTable
objectName={content.data.object.name}
fields={content.data.fields}
entries={content.data.entries}
members={members}
/>
)}
</div>
<ObjectView
data={content.data}
members={members}
onNavigateToObject={onNavigateToObject}
onRefreshObject={onRefreshObject}
/>
);
case "document":
@ -408,6 +450,189 @@ function ContentRenderer({
}
}
// --- Object View (header + display field selector + table/kanban) ---
function ObjectView({
data,
members,
onNavigateToObject,
onRefreshObject,
}: {
data: ObjectData;
members?: Array<{ id: string; name: string; email: string; role: string }>;
onNavigateToObject: (objectName: string) => void;
onRefreshObject: () => void;
}) {
const [updatingDisplayField, setUpdatingDisplayField] = useState(false);
const handleDisplayFieldChange = async (fieldName: string) => {
setUpdatingDisplayField(true);
try {
const res = await fetch(
`/api/workspace/objects/${encodeURIComponent(data.object.name)}/display-field`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayField: fieldName }),
},
);
if (res.ok) {
// Refresh the object data to get updated relation labels
onRefreshObject();
}
} catch {
// ignore
} finally {
setUpdatingDisplayField(false);
}
};
// Fields eligible to be the display field (text-like types)
const displayFieldCandidates = data.fields.filter(
(f) => !["relation", "boolean", "richtext"].includes(f.type),
);
const hasRelationFields = data.fields.some((f) => f.type === "relation");
const hasReverseRelations =
data.reverseRelations && data.reverseRelations.some(
(rr) => Object.keys(rr.entries).length > 0,
);
return (
<div className="p-6">
{/* Object header */}
<div className="mb-6">
<h1
className="text-2xl font-bold capitalize"
style={{ color: "var(--color-text)" }}
>
{data.object.name}
</h1>
{data.object.description && (
<p
className="text-sm mt-1"
style={{ color: "var(--color-text-muted)" }}
>
{data.object.description}
</p>
)}
<div className="flex items-center gap-3 mt-3 flex-wrap">
<span
className="text-xs px-2 py-1 rounded-full"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
}}
>
{data.entries.length} entries
</span>
<span
className="text-xs px-2 py-1 rounded-full"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
}}
>
{data.fields.length} fields
</span>
{/* Relation info badges */}
{hasRelationFields && (
<span
className="text-xs px-2 py-1 rounded-full"
style={{
background: "rgba(96, 165, 250, 0.08)",
color: "#60a5fa",
border: "1px solid rgba(96, 165, 250, 0.2)",
}}
>
{data.fields.filter((f) => f.type === "relation").length} relation{data.fields.filter((f) => f.type === "relation").length !== 1 ? "s" : ""}
</span>
)}
{hasReverseRelations && (
<span
className="text-xs px-2 py-1 rounded-full"
style={{
background: "rgba(192, 132, 252, 0.08)",
color: "#c084fc",
border: "1px solid rgba(192, 132, 252, 0.2)",
}}
>
{data.reverseRelations!.filter((rr) => Object.keys(rr.entries).length > 0).length} linked from
</span>
)}
</div>
{/* Display field selector */}
{displayFieldCandidates.length > 0 && (
<div className="flex items-center gap-2 mt-3">
<span
className="text-xs"
style={{ color: "var(--color-text-muted)" }}
>
Display field:
</span>
<select
value={data.effectiveDisplayField ?? ""}
onChange={(e) => handleDisplayFieldChange(e.target.value)}
disabled={updatingDisplayField}
className="text-xs px-2 py-1 rounded-md outline-none transition-colors cursor-pointer"
style={{
background: "var(--color-surface)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
opacity: updatingDisplayField ? 0.5 : 1,
}}
>
{displayFieldCandidates.map((f) => (
<option key={f.id} value={f.name}>
{f.name}
</option>
))}
</select>
{updatingDisplayField && (
<div
className="w-3 h-3 border border-t-transparent rounded-full animate-spin"
style={{ borderColor: "var(--color-text-muted)" }}
/>
)}
<span
className="text-[10px]"
style={{ color: "var(--color-text-muted)", opacity: 0.6 }}
>
Used when other objects link here
</span>
</div>
)}
</div>
{/* Table or Kanban */}
{data.object.default_view === "kanban" ? (
<ObjectKanban
objectName={data.object.name}
fields={data.fields}
entries={data.entries}
statuses={data.statuses}
members={members}
relationLabels={data.relationLabels}
/>
) : (
<ObjectTable
objectName={data.object.name}
fields={data.fields}
entries={data.entries}
members={members}
relationLabels={data.relationLabels}
reverseRelations={data.reverseRelations}
onNavigateToObject={onNavigateToObject}
/>
)}
</div>
);
}
// --- Directory Listing ---
function DirectoryListing({

View File

@ -91,6 +91,52 @@ export function duckdbQuery<T = Record<string, unknown>>(
}
}
/**
* Execute a DuckDB statement (no JSON output expected).
* Used for INSERT/UPDATE/ALTER operations.
*/
export function duckdbExec(sql: string): boolean {
const db = duckdbPath();
if (!db) {return false;}
const bin = resolveDuckdbBin();
if (!bin) {return false;}
try {
const escapedSql = sql.replace(/'/g, "'\\''");
execSync(`'${bin}' '${db}' '${escapedSql}'`, {
encoding: "utf-8",
timeout: 10_000,
shell: "/bin/sh",
});
return true;
} catch {
return false;
}
}
/**
* Parse a relation field value which may be a single ID or a JSON array of IDs.
* Handles both many_to_one (single ID string) and many_to_many (JSON array).
*/
export function parseRelationValue(value: string | null | undefined): string[] {
if (!value) {return [];}
const trimmed = value.trim();
if (!trimmed) {return [];}
// Try JSON array first (many-to-many)
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {return parsed.map(String).filter(Boolean);}
} catch {
// not valid JSON array, treat as single value
}
}
return [trimmed];
}
/** Database file extensions that trigger the database viewer. */
export const DB_EXTENSIONS = new Set([
"duckdb",
@ -206,6 +252,40 @@ export function parseSimpleYaml(
return result;
}
// --- System file protection ---
const SYSTEM_FILE_PATTERNS = [
/^\.object\.yaml$/,
/^workspace\.duckdb/,
/^workspace_context\.yaml$/,
/\.wal$/,
/\.tmp$/,
];
/** Check if a workspace-relative path refers to a protected system file. */
export function isSystemFile(relativePath: string): boolean {
const base = relativePath.split("/").pop() ?? "";
return SYSTEM_FILE_PATTERNS.some((p) => p.test(base));
}
/**
* Like safeResolvePath but does NOT require the target to exist on disk.
* Useful for mkdir / create / rename-target validation.
* Still prevents path traversal.
*/
export function safeResolveNewPath(relativePath: string): string | null {
const root = resolveDenchRoot();
if (!root) {return null;}
const normalized = normalize(relativePath);
if (normalized.startsWith("..") || normalized.includes("/../")) {return null;}
const absolute = resolve(root, normalized);
if (!absolute.startsWith(resolve(root))) {return null;}
return absolute;
}
/**
* Read a file from the workspace safely.
* Returns content and detected type, or null if not found.

View File

@ -9,6 +9,9 @@
"version": "0.1.0",
"dependencies": {
"@ai-sdk/react": "^3.0.75",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"ai": "^6.0.73",
"next": "^15.3.3",
"react": "^19.1.0",
@ -493,6 +496,59 @@
"resolved": "../../node_modules/.pnpm/@ai-sdk+react@3.0.75_react@19.1.0_zod@4.3.6/node_modules/@ai-sdk/react",
"link": true
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@ -3541,6 +3597,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"resolved": "../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript",
"link": true

View File

@ -11,6 +11,9 @@
},
"dependencies": {
"@ai-sdk/react": "^3.0.75",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"ai": "^6.0.73",
"next": "^15.3.3",
"react": "^19.1.0",

File diff suppressed because one or more lines are too long