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:
parent
49d05a0b1e
commit
6d8623b00f
138
.cursor/plans/file_chat_sidebar_368973cb.plan.md
Normal file
138
.cursor/plans/file_chat_sidebar_368973cb.plan.md
Normal 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
|
||||
258
.cursor/plans/reports_analytics_layer_d6cf8500.plan.md
Normal file
258
.cursor/plans/reports_analytics_layer_d6cf8500.plan.md
Normal 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) |
|
||||
`````
|
||||
282
.cursor/plans/sidebar_file_manager_02ed8b45.plan.md
Normal file
282
.cursor/plans/sidebar_file_manager_02ed8b45.plan.md
Normal 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 |
|
||||
@ -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();
|
||||
|
||||
77
apps/web/app/api/workspace/copy/route.ts
Normal file
77
apps/web/app/api/workspace/copy/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
53
apps/web/app/api/workspace/mkdir/route.ts
Normal file
53
apps/web/app/api/workspace/mkdir/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
93
apps/web/app/api/workspace/move/route.ts
Normal file
93
apps/web/app/api/workspace/move/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
84
apps/web/app/api/workspace/rename/route.ts
Normal file
84
apps/web/app/api/workspace/rename/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
106
apps/web/app/api/workspace/watch/route.ts
Normal file
106
apps/web/app/api/workspace/watch/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
670
apps/web/app/components/chat-panel.tsx
Normal file
670
apps/web/app/components/chat-panel.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
253
apps/web/app/components/workspace/context-menu.tsx
Normal file
253
apps/web/app/components/workspace/context-menu.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
1022
apps/web/app/components/workspace/file-manager-tree.tsx
Normal file
1022
apps/web/app/components/workspace/file-manager-tree.tsx
Normal file
File diff suppressed because it is too large
Load Diff
103
apps/web/app/components/workspace/inline-rename.tsx
Normal file
103
apps/web/app/components/workspace/inline-rename.tsx
Normal 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); }
|
||||
}
|
||||
`;
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
143
apps/web/app/hooks/use-workspace-watcher.ts
Normal file
143
apps/web/app/hooks/use-workspace-watcher.ts
Normal 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 };
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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.
|
||||
|
||||
62
apps/web/package-lock.json
generated
62
apps/web/package-lock.json
generated
@ -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
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user