Merge pull request #1 from kumarabhirup/dench-workspace
Ironclaw v2026.2.10-1: Full rebrand, web UI redesign, workspace engine, and 250+ upstream fixes
This commit is contained in:
commit
c85dc97912
181
.agents/archive/PR_WORKFLOW_V1.md
Normal file
181
.agents/archive/PR_WORKFLOW_V1.md
Normal file
@ -0,0 +1,181 @@
|
||||
# PR Workflow for Maintainers
|
||||
|
||||
Please read this in full and do not skip sections.
|
||||
This is the single source of truth for the maintainer PR workflow.
|
||||
|
||||
## Triage order
|
||||
|
||||
Process PRs **oldest to newest**. Older PRs are more likely to have merge conflicts and stale dependencies; resolving them first keeps the queue healthy and avoids snowballing rebase pain.
|
||||
|
||||
## Working rule
|
||||
|
||||
Skills execute workflow. Maintainers provide judgment.
|
||||
Always pause between skills to evaluate technical direction, not just command success.
|
||||
|
||||
These three skills must be used in order:
|
||||
|
||||
1. `review-pr` — review only, produce findings
|
||||
2. `prepare-pr` — rebase, fix, gate, push to PR head branch
|
||||
3. `merge-pr` — squash-merge, verify MERGED state, clean up
|
||||
|
||||
They are necessary, but not sufficient. Maintainers must steer between steps and understand the code before moving forward.
|
||||
|
||||
Treat PRs as reports first, code second.
|
||||
If submitted code is low quality, ignore it and implement the best solution for the problem.
|
||||
|
||||
Do not continue if you cannot verify the problem is real or test the fix.
|
||||
|
||||
## Coding Agent
|
||||
|
||||
Use ChatGPT 5.3 Codex High. Fall back to 5.2 Codex High or 5.3 Codex Medium if necessary.
|
||||
|
||||
## PR quality bar
|
||||
|
||||
- Do not trust PR code by default.
|
||||
- Do not merge changes you cannot validate with a reproducible problem and a tested fix.
|
||||
- Keep types strict. Do not use `any` in implementation code.
|
||||
- Keep external-input boundaries typed and validated, including CLI input, environment variables, network payloads, and tool output.
|
||||
- Keep implementations properly scoped. Fix root causes, not local symptoms.
|
||||
- Identify and reuse canonical sources of truth so behavior does not drift across the codebase.
|
||||
- Harden changes. Always evaluate security impact and abuse paths.
|
||||
- Understand the system before changing it. Never make the codebase messier just to clear a PR queue.
|
||||
|
||||
## Rebase and conflict resolution
|
||||
|
||||
Before any substantive review or prep work, **always rebase the PR branch onto current `main` and resolve merge conflicts first**. A PR that cannot cleanly rebase is not ready for review — fix conflicts before evaluating correctness.
|
||||
|
||||
- During `prepare-pr`: rebase onto `main` as the first step, before fixing findings or running gates.
|
||||
- If conflicts are complex or touch areas you do not understand, stop and escalate.
|
||||
- Prefer **rebase** for linear history; **squash** when commit history is messy or unhelpful.
|
||||
|
||||
## Commit and changelog rules
|
||||
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- During `prepare-pr`, use this commit subject format: `fix: <summary> (openclaw#<PR>) thanks @<pr-author>`.
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section.
|
||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
||||
- When working on an issue: reference the issue in the changelog entry.
|
||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||
|
||||
## Co-contributor and clawtributors
|
||||
|
||||
- If we squash, add the PR author as a co-contributor in the commit body using a `Co-authored-by:` trailer.
|
||||
- When maintainer prepares and merges the PR, add the maintainer as an additional `Co-authored-by:` trailer too.
|
||||
- Avoid `--auto` merges for maintainer landings. Merge only after checks are green so the maintainer account is the actor and attribution is deterministic.
|
||||
- For squash merges, set `--author-email` to a reviewer-owned email with fallback candidates; if merge fails due to author-email validation, retry once with the next candidate.
|
||||
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
||||
- When merging a PR: leave a PR comment that explains exactly what we did, include the SHA hashes, and record the comment URL in the final report.
|
||||
- When merging a PR from a new contributor: run `bun scripts/update-clawtributors.ts` to add their avatar to the README "Thanks to all clawtributors" list, then commit the regenerated README.
|
||||
|
||||
## Review mode vs landing mode
|
||||
|
||||
- **Review mode (PR link only):** read `gh pr view`/`gh pr diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode (exception path):** use only when normal `review-pr -> prepare-pr -> merge-pr` flow cannot safely preserve attribution or cannot satisfy branch protection. Create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: the contributor needs to be in the git graph after this!
|
||||
|
||||
## Pre-review safety checks
|
||||
|
||||
- Before starting a review when a GH Issue/PR is pasted: use an isolated `.worktrees/pr-<PR>` checkout from `origin/main`. Do not require a clean main checkout, and do not run `git pull` in a dirty main checkout.
|
||||
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- Read `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr)) for what we expect from contributors.
|
||||
|
||||
## Unified workflow
|
||||
|
||||
Entry criteria:
|
||||
|
||||
- PR URL/number is known.
|
||||
- Problem statement is clear enough to attempt reproduction.
|
||||
- A realistic verification path exists (tests, integration checks, or explicit manual validation).
|
||||
|
||||
### 1) `review-pr`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Review only: correctness, value, security risk, tests, docs, and changelog impact.
|
||||
- Produce structured findings and a recommendation.
|
||||
|
||||
Expected output:
|
||||
|
||||
- Recommendation: ready, needs work, needs discussion, or close.
|
||||
- `.local/review.md` with actionable findings.
|
||||
|
||||
Maintainer checkpoint before `prepare-pr`:
|
||||
|
||||
```
|
||||
What problem are they trying to solve?
|
||||
What is the most optimal implementation?
|
||||
Can we fix up everything?
|
||||
Do we have any questions?
|
||||
```
|
||||
|
||||
Stop and escalate instead of continuing if:
|
||||
|
||||
- The problem cannot be reproduced or confirmed.
|
||||
- The proposed PR scope does not match the stated problem.
|
||||
- The design introduces unresolved security or trust-boundary concerns.
|
||||
|
||||
### 2) `prepare-pr`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Make the PR merge-ready on its head branch.
|
||||
- Rebase onto current `main` first, then fix blocker/important findings, then run gates.
|
||||
- In fresh worktrees, bootstrap dependencies before local gates (`pnpm install --frozen-lockfile`).
|
||||
|
||||
Expected output:
|
||||
|
||||
- Updated code and tests on the PR head branch.
|
||||
- `.local/prep.md` with changes, verification, and current HEAD SHA.
|
||||
- Final status: `PR is ready for /mergepr`.
|
||||
|
||||
Maintainer checkpoint before `merge-pr`:
|
||||
|
||||
```
|
||||
Is this the most optimal implementation?
|
||||
Is the code properly scoped?
|
||||
Is the code properly reusing existing logic in the codebase?
|
||||
Is the code properly typed?
|
||||
Is the code hardened?
|
||||
Do we have enough tests?
|
||||
Do we need regression tests?
|
||||
Are tests using fake timers where appropriate? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
|
||||
Do not add performative tests, ensure tests are real and there are no regressions.
|
||||
Do you see any follow-up refactors we should do?
|
||||
Take your time, fix it properly, refactor if necessary.
|
||||
Did any changes introduce any potential security vulnerabilities?
|
||||
```
|
||||
|
||||
Stop and escalate instead of continuing if:
|
||||
|
||||
- You cannot verify behavior changes with meaningful tests or validation.
|
||||
- Fixing findings requires broad architecture changes outside safe PR scope.
|
||||
- Security hardening requirements remain unresolved.
|
||||
|
||||
### 3) `merge-pr`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Merge only after review and prep artifacts are present and checks are green.
|
||||
- Use deterministic squash merge flow (`--match-head-commit` + explicit subject/body with co-author trailer), then verify the PR ends in `MERGED` state.
|
||||
- If no required checks are configured on the PR, treat that as acceptable and continue after branch-up-to-date validation.
|
||||
|
||||
Go or no-go checklist before merge:
|
||||
|
||||
- All BLOCKER and IMPORTANT findings are resolved.
|
||||
- Verification is meaningful and regression risk is acceptably low.
|
||||
- Docs and changelog are updated when required.
|
||||
- Required CI checks are green and the branch is not behind `main`.
|
||||
|
||||
Expected output:
|
||||
|
||||
- Successful merge commit and recorded merge SHA.
|
||||
- Worktree cleanup after successful merge.
|
||||
- Comment on PR indicating merge was successful.
|
||||
|
||||
Maintainer checkpoint after merge:
|
||||
|
||||
- Were any refactors intentionally deferred and now need follow-up issue(s)?
|
||||
- Did this reveal broader architecture or test gaps we should address?
|
||||
- Run `bun scripts/update-clawtributors.ts` if the contributor is new.
|
||||
304
.agents/archive/merge-pr-v1/SKILL.md
Normal file
304
.agents/archive/merge-pr-v1/SKILL.md
Normal file
@ -0,0 +1,304 @@
|
||||
---
|
||||
name: merge-pr
|
||||
description: Merge a GitHub PR via squash after /prepare-pr. Use when asked to merge a ready PR. Do not push to main or modify code. Ensure the PR ends in MERGED state and clean up worktrees after success.
|
||||
---
|
||||
|
||||
# Merge PR
|
||||
|
||||
## Overview
|
||||
|
||||
Merge a prepared PR via deterministic squash merge (`--match-head-commit` + explicit co-author trailer), then clean up the worktree after success.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, use `.local/prep.env` from the worktree if present.
|
||||
- If ambiguous, ask.
|
||||
|
||||
## Safety
|
||||
|
||||
- Use `gh pr merge --squash` as the only path to `main`.
|
||||
- Do not run `git push` at all during merge.
|
||||
- Do not use `gh pr merge --auto` for maintainer landings.
|
||||
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
|
||||
|
||||
## Execution Rule
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs.
|
||||
|
||||
## Known Footguns
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Move to the repo root and retry.
|
||||
- Read `.local/review.md`, `.local/prep.md`, and `.local/prep.env` in the worktree. Do not skip.
|
||||
- Always merge with `--match-head-commit "$PREP_HEAD_SHA"` to prevent racing stale or changed heads.
|
||||
- Clean up `.worktrees/pr-<PR>` only after confirmed `MERGED`.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Ensure `gh pr merge` succeeds.
|
||||
- Ensure PR state is `MERGED`, never `CLOSED`.
|
||||
- Record the merge SHA.
|
||||
- Leave a PR comment with merge SHA and prepared head SHA, and capture the comment URL.
|
||||
- Run cleanup only after merge success.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all merge steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all merge work.
|
||||
|
||||
```sh
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
cd "$repo_root"
|
||||
gh auth status
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
cd "$WORKTREE_DIR"
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
|
||||
## Load Local Artifacts (Mandatory)
|
||||
|
||||
Expect these files from earlier steps:
|
||||
|
||||
- `.local/review.md` from `/review-pr`
|
||||
- `.local/prep.md` from `/prepare-pr`
|
||||
- `.local/prep.env` from `/prepare-pr`
|
||||
|
||||
```sh
|
||||
ls -la .local || true
|
||||
|
||||
for required in .local/review.md .local/prep.md .local/prep.env; do
|
||||
if [ ! -f "$required" ]; then
|
||||
echo "Missing $required. Stop and run /review-pr then /prepare-pr."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
sed -n '1,120p' .local/review.md
|
||||
sed -n '1,120p' .local/prep.md
|
||||
source .local/prep.env
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta and verify prepared SHA still matches
|
||||
|
||||
```sh
|
||||
pr_meta_json=$(gh pr view <PR> --json number,title,state,isDraft,author,headRefName,headRefOid,baseRefName,headRepository,body)
|
||||
printf '%s\n' "$pr_meta_json" | jq '{number,title,state,isDraft,author:.author.login,head:.headRefName,headSha:.headRefOid,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
|
||||
pr_title=$(printf '%s\n' "$pr_meta_json" | jq -r .title)
|
||||
pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
|
||||
pr_head_sha=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefOid)
|
||||
contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
|
||||
is_draft=$(printf '%s\n' "$pr_meta_json" | jq -r .isDraft)
|
||||
|
||||
if [ "$is_draft" = "true" ]; then
|
||||
echo "ERROR: PR is draft. Stop and run /prepare-pr after draft is cleared."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$pr_head_sha" != "$PREP_HEAD_SHA" ]; then
|
||||
echo "ERROR: PR head changed after /prepare-pr (expected $PREP_HEAD_SHA, got $pr_head_sha). Re-run /prepare-pr."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
2. Run sanity checks
|
||||
|
||||
Stop if any are true:
|
||||
|
||||
- PR is a draft.
|
||||
- Required checks are failing.
|
||||
- Branch is behind main.
|
||||
|
||||
If checks are pending, wait for completion before merging. Do not use `--auto`.
|
||||
If no required checks are configured, continue.
|
||||
|
||||
```sh
|
||||
gh pr checks <PR> --required --watch --fail-fast || true
|
||||
checks_json=$(gh pr checks <PR> --required --json name,bucket,state 2>/tmp/gh-checks.err || true)
|
||||
if [ -z "$checks_json" ]; then
|
||||
checks_json='[]'
|
||||
fi
|
||||
required_count=$(printf '%s\n' "$checks_json" | jq 'length')
|
||||
if [ "$required_count" -eq 0 ]; then
|
||||
echo "No required checks configured for this PR."
|
||||
fi
|
||||
printf '%s\n' "$checks_json" | jq -r '.[] | "\(.bucket)\t\(.name)\t\(.state)"'
|
||||
|
||||
failed_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="fail")] | length')
|
||||
pending_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="pending")] | length')
|
||||
if [ "$failed_required" -gt 0 ]; then
|
||||
echo "Required checks are failing, run /prepare-pr."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$pending_required" -gt 0 ]; then
|
||||
echo "Required checks are still pending, retry /merge-pr when green."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin main
|
||||
git fetch origin pull/<PR>/head:pr-<PR> --force
|
||||
git merge-base --is-ancestor origin/main pr-<PR> || (echo "PR branch is behind main, run /prepare-pr" && exit 1)
|
||||
```
|
||||
|
||||
If anything is failing or behind, stop and say to run `/prepare-pr`.
|
||||
|
||||
3. Merge PR with explicit attribution metadata
|
||||
|
||||
```sh
|
||||
reviewer=$(gh api user --jq .login)
|
||||
reviewer_id=$(gh api user --jq .id)
|
||||
coauthor_email=${COAUTHOR_EMAIL:-"$contrib@users.noreply.github.com"}
|
||||
if [ -z "$coauthor_email" ] || [ "$coauthor_email" = "null" ]; then
|
||||
contrib_id=$(gh api users/$contrib --jq .id)
|
||||
coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com"
|
||||
fi
|
||||
|
||||
gh_email=$(gh api user --jq '.email // ""' || true)
|
||||
git_email=$(git config user.email || true)
|
||||
mapfile -t reviewer_email_candidates < <(
|
||||
printf '%s\n' \
|
||||
"$gh_email" \
|
||||
"$git_email" \
|
||||
"${reviewer_id}+${reviewer}@users.noreply.github.com" \
|
||||
"${reviewer}@users.noreply.github.com" | awk 'NF && !seen[$0]++'
|
||||
)
|
||||
[ "${#reviewer_email_candidates[@]}" -gt 0 ] || { echo "ERROR: could not resolve reviewer author email"; exit 1; }
|
||||
reviewer_email="${reviewer_email_candidates[0]}"
|
||||
|
||||
cat > .local/merge-body.txt <<EOF
|
||||
Merged via /review-pr -> /prepare-pr -> /merge-pr.
|
||||
|
||||
Prepared head SHA: $PREP_HEAD_SHA
|
||||
Co-authored-by: $contrib <$coauthor_email>
|
||||
Co-authored-by: $reviewer <$reviewer_email>
|
||||
Reviewed-by: @$reviewer
|
||||
EOF
|
||||
|
||||
run_merge() {
|
||||
local email="$1"
|
||||
local stderr_file
|
||||
stderr_file=$(mktemp)
|
||||
if gh pr merge <PR> \
|
||||
--squash \
|
||||
--delete-branch \
|
||||
--match-head-commit "$PREP_HEAD_SHA" \
|
||||
--author-email "$email" \
|
||||
--subject "$pr_title (#$pr_number)" \
|
||||
--body-file .local/merge-body.txt \
|
||||
2> >(tee "$stderr_file" >&2)
|
||||
then
|
||||
rm -f "$stderr_file"
|
||||
return 0
|
||||
fi
|
||||
merge_err=$(cat "$stderr_file")
|
||||
rm -f "$stderr_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
merge_err=""
|
||||
selected_merge_author_email="$reviewer_email"
|
||||
if ! run_merge "$selected_merge_author_email"; then
|
||||
if printf '%s\n' "$merge_err" | rg -qi 'author.?email|email.*associated|associated.*email|invalid.*email' && [ "${#reviewer_email_candidates[@]}" -ge 2 ]; then
|
||||
selected_merge_author_email="${reviewer_email_candidates[1]}"
|
||||
echo "Retrying once with fallback author email: $selected_merge_author_email"
|
||||
run_merge "$selected_merge_author_email" || { echo "ERROR: merge failed after fallback retry"; exit 1; }
|
||||
else
|
||||
echo "ERROR: merge failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
Retry is allowed exactly once when the error is clearly author-email validation.
|
||||
|
||||
4. Verify PR state and capture merge SHA
|
||||
|
||||
```sh
|
||||
state=$(gh pr view <PR> --json state --jq .state)
|
||||
if [ "$state" != "MERGED" ]; then
|
||||
echo "Merge not finalized yet (state=$state), waiting up to 15 minutes..."
|
||||
for _ in $(seq 1 90); do
|
||||
sleep 10
|
||||
state=$(gh pr view <PR> --json state --jq .state)
|
||||
if [ "$state" = "MERGED" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$state" != "MERGED" ]; then
|
||||
echo "ERROR: PR state is $state after waiting. Leave worktree and retry /merge-pr later."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
|
||||
if [ -z "$merge_sha" ] || [ "$merge_sha" = "null" ]; then
|
||||
echo "ERROR: merge commit SHA missing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
commit_body=$(gh api repos/:owner/:repo/commits/$merge_sha --jq .commit.message)
|
||||
contrib=${contrib:-$(gh pr view <PR> --json author --jq .author.login)}
|
||||
reviewer=${reviewer:-$(gh api user --jq .login)}
|
||||
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $contrib <" || { echo "ERROR: missing PR author co-author trailer"; exit 1; }
|
||||
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $reviewer <" || { echo "ERROR: missing reviewer co-author trailer"; exit 1; }
|
||||
|
||||
echo "merge_sha=$merge_sha"
|
||||
```
|
||||
|
||||
5. PR comment
|
||||
|
||||
Use a multiline heredoc with interpolation enabled.
|
||||
|
||||
```sh
|
||||
ok=0
|
||||
comment_output=""
|
||||
for _ in 1 2 3; do
|
||||
if comment_output=$(gh pr comment <PR> -F - <<EOF
|
||||
Merged via squash.
|
||||
|
||||
- Prepared head SHA: $PREP_HEAD_SHA
|
||||
- Merge commit: $merge_sha
|
||||
|
||||
Thanks @$contrib!
|
||||
EOF
|
||||
); then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
[ "$ok" -eq 1 ] || { echo "ERROR: failed to post PR comment after retries"; exit 1; }
|
||||
comment_url=$(printf '%s\n' "$comment_output" | rg -o 'https://github.com/[^ ]+/pull/[0-9]+#issuecomment-[0-9]+' -m1 || true)
|
||||
[ -n "$comment_url" ] || comment_url="unresolved"
|
||||
echo "comment_url=$comment_url"
|
||||
```
|
||||
|
||||
6. Clean up worktree only on success
|
||||
|
||||
Run cleanup only if step 4 returned `MERGED`.
|
||||
|
||||
```sh
|
||||
cd "$repo_root"
|
||||
git worktree remove ".worktrees/pr-<PR>" --force
|
||||
git branch -D temp/pr-<PR> 2>/dev/null || true
|
||||
git branch -D pr-<PR> 2>/dev/null || true
|
||||
git branch -D pr-<PR>-prep 2>/dev/null || true
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not close PRs.
|
||||
- End in MERGED state.
|
||||
- Clean up only after merge success.
|
||||
- Never push to main. Use `gh pr merge --squash` only.
|
||||
- Do not run `git push` at all in this command.
|
||||
4
.agents/archive/merge-pr-v1/agents/openai.yaml
Normal file
4
.agents/archive/merge-pr-v1/agents/openai.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Merge PR"
|
||||
short_description: "Merge GitHub PRs via squash"
|
||||
default_prompt: "Use $merge-pr to merge a GitHub PR via squash after preparation."
|
||||
336
.agents/archive/prepare-pr-v1/SKILL.md
Normal file
336
.agents/archive/prepare-pr-v1/SKILL.md
Normal file
@ -0,0 +1,336 @@
|
||||
---
|
||||
name: prepare-pr
|
||||
description: Prepare a GitHub PR for merge by rebasing onto main, fixing review findings, running gates, committing fixes, and pushing to the PR head branch. Use after /review-pr. Never merge or push to main.
|
||||
---
|
||||
|
||||
# Prepare PR
|
||||
|
||||
## Overview
|
||||
|
||||
Prepare a PR head branch for merge with review fixes, green gates, and deterministic merge handoff artifacts.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, use `.local/pr-meta.env` from the PR worktree if present.
|
||||
- If ambiguous, ask.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never push to `main` or `origin/main`. Push only to the PR head branch.
|
||||
- Never run `git push` without explicit remote and branch. Do not run bare `git push`.
|
||||
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
|
||||
- Do not run `git clean -fdx`.
|
||||
- Do not run `git add -A` or `git add .`.
|
||||
|
||||
## Execution Rule
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Rebase PR commits onto `origin/main`.
|
||||
- Fix all BLOCKER and IMPORTANT items from `.local/review.md`.
|
||||
- Commit prep changes with required subject format.
|
||||
- Run required gates and pass (`pnpm test` may be skipped only for high-confidence docs-only changes).
|
||||
- Push the updated HEAD back to the PR head branch.
|
||||
- Write `.local/prep.md` and `.local/prep.env`.
|
||||
- Output exactly: `PR is ready for /mergepr`.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all prep steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all prep work.
|
||||
|
||||
```sh
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
cd "$repo_root"
|
||||
gh auth status
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
if [ ! -d "$WORKTREE_DIR" ]; then
|
||||
git fetch origin main
|
||||
git worktree add "$WORKTREE_DIR" -b temp/pr-<PR> origin/main
|
||||
fi
|
||||
cd "$WORKTREE_DIR"
|
||||
mkdir -p .local
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
|
||||
## Load Review Artifacts (Mandatory)
|
||||
|
||||
```sh
|
||||
if [ ! -f .local/review.md ]; then
|
||||
echo "Missing .local/review.md. Run /review-pr first and save findings."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f .local/pr-meta.env ]; then
|
||||
echo "Missing .local/pr-meta.env. Run /review-pr first and save metadata."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sed -n '1,220p' .local/review.md
|
||||
source .local/pr-meta.env
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta with one API call
|
||||
|
||||
```sh
|
||||
pr_meta_json=$(gh pr view <PR> --json number,title,author,headRefName,headRefOid,baseRefName,headRepository,headRepositoryOwner,body)
|
||||
printf '%s\n' "$pr_meta_json" | jq '{number,title,author:.author.login,head:.headRefName,headSha:.headRefOid,base:.baseRefName,headRepo:.headRepository.nameWithOwner,headRepoOwner:.headRepositoryOwner.login,headRepoName:.headRepository.name,body}'
|
||||
|
||||
pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
|
||||
contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
|
||||
head=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefName)
|
||||
pr_head_sha_before=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefOid)
|
||||
head_owner=$(printf '%s\n' "$pr_meta_json" | jq -r '.headRepositoryOwner.login // empty')
|
||||
head_repo_name=$(printf '%s\n' "$pr_meta_json" | jq -r '.headRepository.name // empty')
|
||||
head_repo_url=$(printf '%s\n' "$pr_meta_json" | jq -r '.headRepository.url // empty')
|
||||
|
||||
if [ -n "${PR_HEAD:-}" ] && [ "$head" != "$PR_HEAD" ]; then
|
||||
echo "ERROR: PR head branch changed from $PR_HEAD to $head. Re-run /review-pr."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
2. Fetch PR head and rebase on latest `origin/main`
|
||||
|
||||
```sh
|
||||
git fetch origin pull/<PR>/head:pr-<PR> --force
|
||||
git checkout -B pr-<PR>-prep pr-<PR>
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
If conflicts happen:
|
||||
|
||||
- Resolve each conflicted file.
|
||||
- Run `git add <resolved_file>` for each file.
|
||||
- Run `git rebase --continue`.
|
||||
|
||||
If the rebase gets confusing or you resolve conflicts 3 or more times, stop and report.
|
||||
|
||||
3. Fix issues from `.local/review.md`
|
||||
|
||||
- Fix all BLOCKER and IMPORTANT items.
|
||||
- NITs are optional.
|
||||
- Keep scope tight.
|
||||
|
||||
Keep a running log in `.local/prep.md`:
|
||||
|
||||
- List which review items you fixed.
|
||||
- List which files you touched.
|
||||
- Note behavior changes.
|
||||
|
||||
4. Optional quick feedback tests before full gates
|
||||
|
||||
Targeted tests are optional quick feedback, not a substitute for full gates.
|
||||
|
||||
If running targeted tests in a fresh worktree:
|
||||
|
||||
```sh
|
||||
if [ ! -x node_modules/.bin/vitest ]; then
|
||||
pnpm install --frozen-lockfile
|
||||
fi
|
||||
```
|
||||
|
||||
5. Commit prep fixes with required subject format
|
||||
|
||||
Use `scripts/committer` with explicit file paths.
|
||||
|
||||
Required subject format:
|
||||
|
||||
- `fix: <summary> (openclaw#<PR>) thanks @<author>`
|
||||
|
||||
```sh
|
||||
commit_msg="fix: <summary> (openclaw#$pr_number) thanks @$contrib"
|
||||
scripts/committer "$commit_msg" <changed file 1> <changed file 2> ...
|
||||
```
|
||||
|
||||
If there are no local changes, do not create a no-op commit.
|
||||
|
||||
Post-commit validation (mandatory):
|
||||
|
||||
```sh
|
||||
subject=$(git log -1 --pretty=%s)
|
||||
echo "$subject" | rg -q "openclaw#$pr_number" || { echo "ERROR: commit subject missing openclaw#$pr_number"; exit 1; }
|
||||
echo "$subject" | rg -q "thanks @$contrib" || { echo "ERROR: commit subject missing thanks @$contrib"; exit 1; }
|
||||
```
|
||||
|
||||
6. Decide verification mode and run required gates before pushing
|
||||
|
||||
If you are highly confident the change is docs-only, you may skip `pnpm test`.
|
||||
|
||||
High-confidence docs-only criteria (all must be true):
|
||||
|
||||
- Every changed file is documentation-only (`docs/**`, `README*.md`, `CHANGELOG.md`, `*.md`, `*.mdx`, `mintlify.json`, `docs.json`).
|
||||
- No code, runtime, test, dependency, or build config files changed (`src/**`, `extensions/**`, `apps/**`, `package.json`, lockfiles, TS/JS config, test files, scripts).
|
||||
- `.local/review.md` does not call for non-doc behavior fixes.
|
||||
|
||||
Suggested check:
|
||||
|
||||
```sh
|
||||
changed_files=$(git diff --name-only origin/main...HEAD)
|
||||
non_docs=$(printf "%s\n" "$changed_files" | grep -Ev '^(docs/|README.*\.md$|CHANGELOG\.md$|.*\.md$|.*\.mdx$|mintlify\.json$|docs\.json$)' || true)
|
||||
|
||||
docs_only=false
|
||||
if [ -n "$changed_files" ] && [ -z "$non_docs" ]; then
|
||||
docs_only=true
|
||||
fi
|
||||
|
||||
echo "docs_only=$docs_only"
|
||||
```
|
||||
|
||||
Bootstrap dependencies in a fresh worktree before gates:
|
||||
|
||||
```sh
|
||||
if [ ! -d node_modules ]; then
|
||||
pnpm install --frozen-lockfile
|
||||
fi
|
||||
```
|
||||
|
||||
Run required gates:
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
pnpm check
|
||||
|
||||
if [ "$docs_only" = "true" ]; then
|
||||
echo "Docs-only change detected with high confidence; skipping pnpm test." | tee -a .local/prep.md
|
||||
else
|
||||
pnpm test
|
||||
fi
|
||||
```
|
||||
|
||||
Require all required gates to pass. If something fails, fix, commit, and rerun. Allow at most 3 fix-and-rerun cycles.
|
||||
|
||||
7. Push safely to the PR head branch
|
||||
|
||||
Build `prhead` from owner/name first, then validate remote branch SHA before push.
|
||||
|
||||
```sh
|
||||
if [ -n "$head_owner" ] && [ -n "$head_repo_name" ]; then
|
||||
head_repo_push_url="https://github.com/$head_owner/$head_repo_name.git"
|
||||
elif [ -n "$head_repo_url" ] && [ "$head_repo_url" != "null" ]; then
|
||||
case "$head_repo_url" in
|
||||
*.git) head_repo_push_url="$head_repo_url" ;;
|
||||
*) head_repo_push_url="$head_repo_url.git" ;;
|
||||
esac
|
||||
else
|
||||
echo "ERROR: unable to determine PR head repo push URL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git remote add prhead "$head_repo_push_url" 2>/dev/null || git remote set-url prhead "$head_repo_push_url"
|
||||
|
||||
echo "Pushing to branch: $head"
|
||||
if [ "$head" = "main" ] || [ "$head" = "master" ]; then
|
||||
echo "ERROR: head branch is main/master. This is wrong. Stopping."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
remote_sha=$(git ls-remote prhead "refs/heads/$head" | awk '{print $1}')
|
||||
if [ -z "$remote_sha" ]; then
|
||||
echo "ERROR: remote branch refs/heads/$head not found on prhead"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$remote_sha" != "$pr_head_sha_before" ]; then
|
||||
echo "ERROR: expected remote SHA $pr_head_sha_before, got $remote_sha. Re-fetch metadata and rebase first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git push --force-with-lease=refs/heads/$head:$pr_head_sha_before prhead HEAD:$head || push_failed=1
|
||||
```
|
||||
|
||||
If lease push fails because head moved, perform one automatic retry:
|
||||
|
||||
```sh
|
||||
if [ "${push_failed:-0}" = "1" ]; then
|
||||
echo "Lease push failed, retrying once with fresh PR head..."
|
||||
|
||||
pr_head_sha_before=$(gh pr view <PR> --json headRefOid --jq .headRefOid)
|
||||
git fetch origin pull/<PR>/head:pr-<PR>-latest --force
|
||||
git rebase pr-<PR>-latest
|
||||
|
||||
pnpm build
|
||||
pnpm check
|
||||
if [ "$docs_only" != "true" ]; then
|
||||
pnpm test
|
||||
fi
|
||||
|
||||
git push --force-with-lease=refs/heads/$head:$pr_head_sha_before prhead HEAD:$head
|
||||
fi
|
||||
```
|
||||
|
||||
8. Verify PR head and base relation (Mandatory)
|
||||
|
||||
```sh
|
||||
prep_head_sha=$(git rev-parse HEAD)
|
||||
pr_head_sha_after=$(gh pr view <PR> --json headRefOid --jq .headRefOid)
|
||||
|
||||
if [ "$prep_head_sha" != "$pr_head_sha_after" ]; then
|
||||
echo "ERROR: pushed head SHA does not match PR head SHA."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin main
|
||||
git fetch origin pull/<PR>/head:pr-<PR>-verify --force
|
||||
git merge-base --is-ancestor origin/main pr-<PR>-verify && echo "PR is up to date with main" || (echo "ERROR: PR is still behind main, rebase again" && exit 1)
|
||||
git branch -D pr-<PR>-verify 2>/dev/null || true
|
||||
```
|
||||
|
||||
9. Write prep summary artifacts (Mandatory)
|
||||
|
||||
Write `.local/prep.md` and `.local/prep.env` for merge handoff.
|
||||
|
||||
```sh
|
||||
contrib_id=$(gh api users/$contrib --jq .id)
|
||||
coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com"
|
||||
|
||||
cat > .local/prep.env <<EOF_ENV
|
||||
PR_NUMBER=$pr_number
|
||||
PR_AUTHOR=$contrib
|
||||
PR_HEAD=$head
|
||||
PR_HEAD_SHA_BEFORE=$pr_head_sha_before
|
||||
PREP_HEAD_SHA=$prep_head_sha
|
||||
COAUTHOR_EMAIL=$coauthor_email
|
||||
EOF_ENV
|
||||
|
||||
ls -la .local/prep.md .local/prep.env
|
||||
wc -l .local/prep.md .local/prep.env
|
||||
```
|
||||
|
||||
10. Output
|
||||
|
||||
Include a diff stat summary:
|
||||
|
||||
```sh
|
||||
git diff --stat origin/main..HEAD
|
||||
git diff --shortstat origin/main..HEAD
|
||||
```
|
||||
|
||||
Report totals: X files changed, Y insertions(+), Z deletions(-).
|
||||
|
||||
If gates passed and push succeeded, print exactly:
|
||||
|
||||
```
|
||||
PR is ready for /mergepr
|
||||
```
|
||||
|
||||
Otherwise, list remaining failures and stop.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not delete the worktree on success. `/mergepr` may reuse it.
|
||||
- Do not run `gh pr merge`.
|
||||
- Never push to main. Only push to the PR head branch.
|
||||
- Run and pass all required gates before pushing. `pnpm test` may be skipped only for high-confidence docs-only changes, and the skip must be explicitly recorded in `.local/prep.md`.
|
||||
4
.agents/archive/prepare-pr-v1/agents/openai.yaml
Normal file
4
.agents/archive/prepare-pr-v1/agents/openai.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Prepare PR"
|
||||
short_description: "Prepare GitHub PRs for merge"
|
||||
default_prompt: "Use $prepare-pr to prep a GitHub PR for merge without merging."
|
||||
253
.agents/archive/review-pr-v1/SKILL.md
Normal file
253
.agents/archive/review-pr-v1/SKILL.md
Normal file
@ -0,0 +1,253 @@
|
||||
---
|
||||
name: review-pr
|
||||
description: Review-only GitHub pull request analysis with the gh CLI. Use when asked to review a PR, provide structured feedback, or assess readiness to land. Do not merge, push, or make code changes you intend to keep.
|
||||
---
|
||||
|
||||
# Review PR
|
||||
|
||||
## Overview
|
||||
|
||||
Perform a thorough review-only PR assessment and return a structured recommendation on readiness for /prepare-pr.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, always ask. Never auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never push to `main` or `origin/main`, not during review, not ever.
|
||||
- Do not run `git push` at all during review. Treat review as read only.
|
||||
- Do not stop or kill the gateway. Do not run gateway stop commands. Do not kill processes on port 18792.
|
||||
|
||||
## Execution Rule
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs, not a plan.
|
||||
|
||||
## Known Failure Modes
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Move to the repository root and retry.
|
||||
- Do not stop after printing the checklist. That is not completion.
|
||||
|
||||
## Writing Style for Output
|
||||
|
||||
- Write casual and direct.
|
||||
- Avoid em dashes and en dashes. Use commas or separate sentences.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Run the commands in the worktree and inspect the PR directly.
|
||||
- Produce the structured review sections A through J.
|
||||
- Save the full review to `.local/review.md` inside the worktree.
|
||||
- Save PR metadata handoff to `.local/pr-meta.env` inside the worktree.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all review steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all review work.
|
||||
|
||||
```sh
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
cd "$repo_root"
|
||||
gh auth status
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
git fetch origin main
|
||||
|
||||
# Reuse existing worktree if it exists, otherwise create new
|
||||
if [ -d "$WORKTREE_DIR" ]; then
|
||||
git worktree list
|
||||
cd "$WORKTREE_DIR"
|
||||
git fetch origin main
|
||||
git checkout -B temp/pr-<PR> origin/main
|
||||
else
|
||||
git worktree add "$WORKTREE_DIR" -b temp/pr-<PR> origin/main
|
||||
cd "$WORKTREE_DIR"
|
||||
fi
|
||||
|
||||
# Create local scratch space that persists across /review-pr to /prepare-pr to /merge-pr
|
||||
mkdir -p .local
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
Start on `origin/main` so you can check for existing implementations before looking at PR code.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta and context
|
||||
|
||||
```sh
|
||||
pr_meta_json=$(gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions,statusCheckRollup)
|
||||
printf '%s\n' "$pr_meta_json" | jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headSha:.headRefOid,headRepo:.headRepository.nameWithOwner,additions,deletions,files:(.files|length),body}'
|
||||
|
||||
cat > .local/pr-meta.env <<EOF
|
||||
PR_NUMBER=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
|
||||
PR_URL=$(printf '%s\n' "$pr_meta_json" | jq -r .url)
|
||||
PR_AUTHOR=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
|
||||
PR_BASE=$(printf '%s\n' "$pr_meta_json" | jq -r .baseRefName)
|
||||
PR_HEAD=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefName)
|
||||
PR_HEAD_SHA=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefOid)
|
||||
PR_HEAD_REPO=$(printf '%s\n' "$pr_meta_json" | jq -r .headRepository.nameWithOwner)
|
||||
EOF
|
||||
|
||||
ls -la .local/pr-meta.env
|
||||
```
|
||||
|
||||
2. Check if this already exists in main before looking at the PR branch
|
||||
|
||||
- Identify the core feature or fix from the PR title and description.
|
||||
- Search for existing implementations using keywords from the PR title, changed file paths, and function or component names from the diff.
|
||||
|
||||
```sh
|
||||
# Use keywords from the PR title and changed files
|
||||
rg -n "<keyword_from_pr_title>" -S src packages apps ui || true
|
||||
rg -n "<function_or_component_name>" -S src packages apps ui || true
|
||||
|
||||
git log --oneline --all --grep="<keyword_from_pr_title>" | head -20
|
||||
```
|
||||
|
||||
If it already exists, call it out as a BLOCKER or at least IMPORTANT.
|
||||
|
||||
3. Claim the PR
|
||||
|
||||
Assign yourself so others know someone is reviewing. Skip if the PR looks like spam or is a draft you plan to recommend closing.
|
||||
|
||||
```sh
|
||||
gh_user=$(gh api user --jq .login)
|
||||
gh pr edit <PR> --add-assignee "$gh_user" || echo "Could not assign reviewer, continuing"
|
||||
```
|
||||
|
||||
4. Read the PR description carefully
|
||||
|
||||
Use the body from step 1. Summarize goal, scope, and missing context.
|
||||
|
||||
5. Read the diff thoroughly
|
||||
|
||||
Minimum:
|
||||
|
||||
```sh
|
||||
gh pr diff <PR>
|
||||
```
|
||||
|
||||
If you need full code context locally, fetch the PR head to a local ref and diff it. Do not create a merge commit.
|
||||
|
||||
```sh
|
||||
git fetch origin pull/<PR>/head:pr-<PR> --force
|
||||
mb=$(git merge-base origin/main pr-<PR>)
|
||||
|
||||
# Show only this PR patch relative to merge-base, not total branch drift
|
||||
git diff --stat "$mb"..pr-<PR>
|
||||
git diff "$mb"..pr-<PR>
|
||||
```
|
||||
|
||||
If you want to browse the PR version of files directly, temporarily check out `pr-<PR>` in the worktree. Do not commit or push. Return to `temp/pr-<PR>` and reset to `origin/main` afterward.
|
||||
|
||||
```sh
|
||||
# Use only if needed
|
||||
# git checkout pr-<PR>
|
||||
# git branch --show-current
|
||||
# ...inspect files...
|
||||
|
||||
git checkout temp/pr-<PR>
|
||||
git checkout -B temp/pr-<PR> origin/main
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
6. Validate the change is needed and valuable
|
||||
|
||||
Be honest. Call out low value AI slop.
|
||||
|
||||
7. Evaluate implementation quality
|
||||
|
||||
Review correctness, design, performance, and ergonomics.
|
||||
|
||||
8. Perform a security review
|
||||
|
||||
Assume OpenClaw subagents run with full disk access, including git, gh, and shell. Check auth, input validation, secrets, dependencies, tool safety, and privacy.
|
||||
|
||||
9. Review tests and verification
|
||||
|
||||
Identify what exists, what is missing, and what would be a minimal regression test.
|
||||
|
||||
If you run local tests in the worktree, bootstrap dependencies first:
|
||||
|
||||
```sh
|
||||
if [ ! -x node_modules/.bin/vitest ]; then
|
||||
pnpm install --frozen-lockfile
|
||||
fi
|
||||
```
|
||||
|
||||
10. Check docs
|
||||
|
||||
Check if the PR touches code with related documentation such as README, docs, inline API docs, or config examples.
|
||||
|
||||
- If docs exist for the changed area and the PR does not update them, flag as IMPORTANT.
|
||||
- If the PR adds a new feature or config option with no docs, flag as IMPORTANT.
|
||||
- If the change is purely internal with no user-facing impact, skip this.
|
||||
|
||||
11. Check changelog
|
||||
|
||||
Check if `CHANGELOG.md` exists and whether the PR warrants an entry.
|
||||
|
||||
- If the project has a changelog and the PR is user-facing, flag missing entry as IMPORTANT.
|
||||
- Leave the change for /prepare-pr, only flag it here.
|
||||
|
||||
12. Answer the key question
|
||||
|
||||
Decide if /prepare-pr can fix issues or the contributor must update the PR.
|
||||
|
||||
13. Save findings to the worktree
|
||||
|
||||
Write the full structured review sections A through J to `.local/review.md`.
|
||||
Create or overwrite the file and verify it exists and is non-empty.
|
||||
|
||||
```sh
|
||||
ls -la .local/review.md
|
||||
wc -l .local/review.md
|
||||
```
|
||||
|
||||
14. Output the structured review
|
||||
|
||||
Produce a review that matches what you saved to `.local/review.md`.
|
||||
|
||||
A) TL;DR recommendation
|
||||
|
||||
- One of: READY FOR /prepare-pr | NEEDS WORK | NEEDS DISCUSSION | NOT USEFUL (CLOSE)
|
||||
- 1 to 3 sentences.
|
||||
|
||||
B) What changed
|
||||
|
||||
C) What is good
|
||||
|
||||
D) Security findings
|
||||
|
||||
E) Concerns or questions (actionable)
|
||||
|
||||
- Numbered list.
|
||||
- Mark each item as BLOCKER, IMPORTANT, or NIT.
|
||||
- For each, point to file or area and propose a concrete fix.
|
||||
|
||||
F) Tests
|
||||
|
||||
G) Docs status
|
||||
|
||||
- State if related docs are up to date, missing, or not applicable.
|
||||
|
||||
H) Changelog
|
||||
|
||||
- State if `CHANGELOG.md` needs an entry and which category.
|
||||
|
||||
I) Follow ups (optional)
|
||||
|
||||
J) Suggested PR comment (optional)
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not delete the worktree after review.
|
||||
- Review only, do not merge, do not push.
|
||||
4
.agents/archive/review-pr-v1/agents/openai.yaml
Normal file
4
.agents/archive/review-pr-v1/agents/openai.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Review PR"
|
||||
short_description: "Review GitHub PRs without merging"
|
||||
default_prompt: "Use $review-pr to perform a thorough, review-only GitHub PR review."
|
||||
@ -1,17 +1,22 @@
|
||||
# PR Review Instructions
|
||||
# PR Workflow for Maintainers
|
||||
|
||||
Please read this in full and do not skip sections.
|
||||
This is the single source of truth for the maintainer PR workflow.
|
||||
|
||||
## Triage order
|
||||
|
||||
Process PRs **oldest to newest**. Older PRs are more likely to have merge conflicts and stale dependencies; resolving them first keeps the queue healthy and avoids snowballing rebase pain.
|
||||
|
||||
## Working rule
|
||||
|
||||
Skills execute workflow, maintainers provide judgment.
|
||||
Skills execute workflow. Maintainers provide judgment.
|
||||
Always pause between skills to evaluate technical direction, not just command success.
|
||||
|
||||
These three skills must be used in order:
|
||||
|
||||
1. `review-pr`
|
||||
2. `prepare-pr`
|
||||
3. `merge-pr`
|
||||
1. `review-pr` — review only, produce findings
|
||||
2. `prepare-pr` — rebase, fix, gate, push to PR head branch
|
||||
3. `merge-pr` — squash-merge, verify MERGED state, clean up
|
||||
|
||||
They are necessary, but not sufficient. Maintainers must steer between steps and understand the code before moving forward.
|
||||
|
||||
@ -20,6 +25,65 @@ If submitted code is low quality, ignore it and implement the best solution for
|
||||
|
||||
Do not continue if you cannot verify the problem is real or test the fix.
|
||||
|
||||
## Script-first contract
|
||||
|
||||
Skill runs should invoke these wrappers automatically. You only need to run them manually when debugging or doing an explicit script-only run:
|
||||
|
||||
- `scripts/pr-review <PR>`
|
||||
- `scripts/pr review-checkout-main <PR>` or `scripts/pr review-checkout-pr <PR>` while reviewing
|
||||
- `scripts/pr review-guard <PR>` before writing review outputs
|
||||
- `scripts/pr review-validate-artifacts <PR>` after writing outputs
|
||||
- `scripts/pr-prepare init <PR>`
|
||||
- `scripts/pr-prepare validate-commit <PR>`
|
||||
- `scripts/pr-prepare gates <PR>`
|
||||
- `scripts/pr-prepare push <PR>`
|
||||
- Optional one-shot prepare: `scripts/pr-prepare run <PR>`
|
||||
- `scripts/pr-merge <PR>` (verify-only; short form remains backward compatible)
|
||||
- `scripts/pr-merge verify <PR>` (verify-only)
|
||||
- Optional one-shot merge: `scripts/pr-merge run <PR>`
|
||||
|
||||
These wrappers run shared preflight checks and generate deterministic artifacts. They are designed to work from repo root or PR worktree cwd.
|
||||
|
||||
## Required artifacts
|
||||
|
||||
- `.local/pr-meta.json` and `.local/pr-meta.env` from review init.
|
||||
- `.local/review.md` and `.local/review.json` from review output.
|
||||
- `.local/prep-context.env` and `.local/prep.md` from prepare.
|
||||
- `.local/prep.env` from prepare completion.
|
||||
|
||||
## Structured review handoff
|
||||
|
||||
`review-pr` must write `.local/review.json`.
|
||||
In normal skill runs this is handled automatically. Use `scripts/pr review-artifacts-init <PR>` and `scripts/pr review-tests <PR> ...` manually only for debugging or explicit script-only runs.
|
||||
|
||||
Minimum schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"recommendation": "READY FOR /prepare-pr",
|
||||
"findings": [
|
||||
{
|
||||
"id": "F1",
|
||||
"severity": "IMPORTANT",
|
||||
"title": "Missing changelog entry",
|
||||
"area": "CHANGELOG.md",
|
||||
"fix": "Add a Fixes entry for PR #<PR>"
|
||||
}
|
||||
],
|
||||
"tests": {
|
||||
"ran": ["pnpm test -- ..."],
|
||||
"gaps": ["..."],
|
||||
"result": "pass"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`prepare-pr` resolves all `BLOCKER` and `IMPORTANT` findings from this file.
|
||||
|
||||
## Coding Agent
|
||||
|
||||
Use ChatGPT 5.3 Codex High. Fall back to 5.2 Codex High or 5.3 Codex Medium if necessary.
|
||||
|
||||
## PR quality bar
|
||||
|
||||
- Do not trust PR code by default.
|
||||
@ -31,6 +95,60 @@ Do not continue if you cannot verify the problem is real or test the fix.
|
||||
- Harden changes. Always evaluate security impact and abuse paths.
|
||||
- Understand the system before changing it. Never make the codebase messier just to clear a PR queue.
|
||||
|
||||
## Rebase and conflict resolution
|
||||
|
||||
Before any substantive review or prep work, **always rebase the PR branch onto current `main` and resolve merge conflicts first**. A PR that cannot cleanly rebase is not ready for review — fix conflicts before evaluating correctness.
|
||||
|
||||
- During `prepare-pr`: rebase onto `main` as the first step, before fixing findings or running gates.
|
||||
- If conflicts are complex or touch areas you do not understand, stop and escalate.
|
||||
- Prefer **rebase** for linear history; **squash** when commit history is messy or unhelpful.
|
||||
|
||||
## Commit and changelog rules
|
||||
|
||||
- In normal `prepare-pr` runs, commits are created via `scripts/committer "<msg>" <file...>`. Use it manually only when operating outside the skill flow; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- During `prepare-pr`, use this commit subject format: `fix: <summary> (openclaw#<PR>) thanks @<pr-author>`.
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section.
|
||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
||||
- When working on an issue: reference the issue in the changelog entry.
|
||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||
|
||||
## Gate policy
|
||||
|
||||
In fresh worktrees, dependency bootstrap is handled by wrappers before local gates. Manual equivalent:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Gate set:
|
||||
|
||||
- Always: `pnpm build`, `pnpm check`
|
||||
- `pnpm test` required unless high-confidence docs-only criteria pass.
|
||||
|
||||
## Co-contributor and clawtributors
|
||||
|
||||
- If we squash, add the PR author as a co-contributor in the commit body using a `Co-authored-by:` trailer.
|
||||
- When maintainer prepares and merges the PR, add the maintainer as an additional `Co-authored-by:` trailer too.
|
||||
- Avoid `--auto` merges for maintainer landings. Merge only after checks are green so the maintainer account is the actor and attribution is deterministic.
|
||||
- For squash merges, set `--author-email` to a reviewer-owned email with fallback candidates; if merge fails due to author-email validation, retry once with the next candidate.
|
||||
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
||||
- When merging a PR: leave a PR comment that explains exactly what we did, include the SHA hashes, and record the comment URL in the final report.
|
||||
- Manual post-merge step for new contributors: run `bun scripts/update-clawtributors.ts` to add their avatar to the README "Thanks to all clawtributors" list, then commit the regenerated README.
|
||||
|
||||
## Review mode vs landing mode
|
||||
|
||||
- **Review mode (PR link only):** read `gh pr view`/`gh pr diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode (exception path):** use only when normal `review-pr -> prepare-pr -> merge-pr` flow cannot safely preserve attribution or cannot satisfy branch protection. Create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: the contributor needs to be in the git graph after this!
|
||||
|
||||
## Pre-review safety checks
|
||||
|
||||
- Before starting a review when a GH Issue/PR is pasted: `review-pr`/`scripts/pr-review` should create and use an isolated `.worktrees/pr-<PR>` checkout from `origin/main` automatically. Do not require a clean main checkout, and do not run `git pull` in a dirty main checkout.
|
||||
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- Read `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr)) for what we expect from contributors.
|
||||
|
||||
## Unified workflow
|
||||
|
||||
Entry criteria:
|
||||
@ -56,7 +174,6 @@ Maintainer checkpoint before `prepare-pr`:
|
||||
```
|
||||
What problem are they trying to solve?
|
||||
What is the most optimal implementation?
|
||||
Is the code properly scoped?
|
||||
Can we fix up everything?
|
||||
Do we have any questions?
|
||||
```
|
||||
@ -72,27 +189,30 @@ Stop and escalate instead of continuing if:
|
||||
Purpose:
|
||||
|
||||
- Make the PR merge-ready on its head branch.
|
||||
- Rebase onto current `main`, fix blocker/important findings, and run gates.
|
||||
- Rebase onto current `main` first, then fix blocker/important findings, then run gates.
|
||||
- In fresh worktrees, bootstrap dependencies before local gates (`pnpm install --frozen-lockfile`).
|
||||
|
||||
Expected output:
|
||||
|
||||
- Updated code and tests on the PR head branch.
|
||||
- `.local/prep.md` with changes, verification, and current HEAD SHA.
|
||||
- Final status: `PR is ready for /mergepr`.
|
||||
- Final status: `PR is ready for /merge-pr`.
|
||||
|
||||
Maintainer checkpoint before `merge-pr`:
|
||||
|
||||
```
|
||||
Is this the most optimal implementation?
|
||||
Is the code properly scoped?
|
||||
Is the code properly reusing existing logic in the codebase?
|
||||
Is the code properly typed?
|
||||
Is the code hardened?
|
||||
Do we have enough tests?
|
||||
Are tests using fake timers where relevant? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
|
||||
Do we need regression tests?
|
||||
Are tests using fake timers where appropriate? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
|
||||
Do not add performative tests, ensure tests are real and there are no regressions.
|
||||
Take your time, fix it properly, refactor if necessary.
|
||||
Do you see any follow-up refactors we should do?
|
||||
Did any changes introduce any potential security vulnerabilities?
|
||||
Take your time, fix it properly, refactor if necessary.
|
||||
```
|
||||
|
||||
Stop and escalate instead of continuing if:
|
||||
@ -106,7 +226,8 @@ Stop and escalate instead of continuing if:
|
||||
Purpose:
|
||||
|
||||
- Merge only after review and prep artifacts are present and checks are green.
|
||||
- Use squash merge flow and verify the PR ends in `MERGED` state.
|
||||
- Use deterministic squash merge flow (`--match-head-commit` + explicit subject/body with co-author trailer), then verify the PR ends in `MERGED` state.
|
||||
- If no required checks are configured on the PR, treat that as acceptable and continue after branch-up-to-date validation.
|
||||
|
||||
Go or no-go checklist before merge:
|
||||
|
||||
@ -119,8 +240,10 @@ Expected output:
|
||||
|
||||
- Successful merge commit and recorded merge SHA.
|
||||
- Worktree cleanup after successful merge.
|
||||
- Comment on PR indicating merge was successful.
|
||||
|
||||
Maintainer checkpoint after merge:
|
||||
|
||||
- Were any refactors intentionally deferred and now need follow-up issue(s)?
|
||||
- Did this reveal broader architecture or test gaps we should address?
|
||||
- Run `bun scripts/update-clawtributors.ts` if the contributor is new.
|
||||
|
||||
@ -1,185 +1,98 @@
|
||||
---
|
||||
name: merge-pr
|
||||
description: Merge a GitHub PR via squash after /preparepr. Use when asked to merge a ready PR. Do not push to main or modify code. Ensure the PR ends in MERGED state and clean up worktrees after success.
|
||||
description: Script-first deterministic squash merge with strict required-check gating, head-SHA pinning, and reliable attribution/commenting.
|
||||
---
|
||||
|
||||
# Merge PR
|
||||
|
||||
## Overview
|
||||
|
||||
Merge a prepared PR via `gh pr merge --squash` and clean up the worktree after success.
|
||||
Merge a prepared PR only after deterministic validation.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
- If missing, use `.local/prep.env` from the PR worktree.
|
||||
|
||||
## Safety
|
||||
|
||||
- Use `gh pr merge --squash` as the only path to `main`.
|
||||
- Do not run `git push` at all during merge.
|
||||
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
|
||||
- Never use `gh pr merge --auto` in this flow.
|
||||
- Never run `git push` directly.
|
||||
- Require `--match-head-commit` during merge.
|
||||
|
||||
## Execution Rule
|
||||
## Execution Contract
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs.
|
||||
|
||||
## Known Footguns
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
|
||||
- Read `.local/review.md` and `.local/prep.md` in the worktree. Do not skip.
|
||||
- Clean up the real worktree directory `.worktrees/pr-<PR>` only after a successful merge.
|
||||
- Expect cleanup to remove `.local/` artifacts.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Ensure `gh pr merge` succeeds.
|
||||
- Ensure PR state is `MERGED`, never `CLOSED`.
|
||||
- Record the merge SHA.
|
||||
- Run cleanup only after merge success.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all merge steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all merge work.
|
||||
1. Validate merge readiness:
|
||||
|
||||
```sh
|
||||
cd ~/dev/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
scripts/pr-merge verify <PR>
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
|
||||
## Load Local Artifacts (Mandatory)
|
||||
|
||||
Expect these files from earlier steps:
|
||||
|
||||
- `.local/review.md` from `/reviewpr`
|
||||
- `.local/prep.md` from `/preparepr`
|
||||
Backward-compatible verify form also works:
|
||||
|
||||
```sh
|
||||
ls -la .local || true
|
||||
|
||||
if [ -f .local/review.md ]; then
|
||||
echo "Found .local/review.md"
|
||||
sed -n '1,120p' .local/review.md
|
||||
else
|
||||
echo "Missing .local/review.md. Stop and run /reviewpr, then /preparepr."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f .local/prep.md ]; then
|
||||
echo "Found .local/prep.md"
|
||||
sed -n '1,120p' .local/prep.md
|
||||
else
|
||||
echo "Missing .local/prep.md. Stop and run /preparepr first."
|
||||
exit 1
|
||||
fi
|
||||
scripts/pr-merge <PR>
|
||||
```
|
||||
|
||||
2. Run one-shot deterministic merge:
|
||||
|
||||
```sh
|
||||
scripts/pr-merge run <PR>
|
||||
```
|
||||
|
||||
3. Ensure output reports:
|
||||
|
||||
- `merge_sha=<sha>`
|
||||
- `merge_author_email=<email>`
|
||||
- `comment_url=<url>`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta
|
||||
1. Validate artifacts
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,state,isDraft,author,headRefName,baseRefName,headRepository,body --jq '{number,title,state,isDraft,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
require=(.local/review.md .local/review.json .local/prep.md .local/prep.env)
|
||||
for f in "${require[@]}"; do
|
||||
[ -s "$f" ] || { echo "Missing artifact: $f"; exit 1; }
|
||||
done
|
||||
```
|
||||
|
||||
2. Run sanity checks
|
||||
|
||||
Stop if any are true:
|
||||
|
||||
- PR is a draft.
|
||||
- Required checks are failing.
|
||||
- Branch is behind main.
|
||||
2. Validate checks and branch status
|
||||
|
||||
```sh
|
||||
# Checks
|
||||
gh pr checks <PR>
|
||||
|
||||
# Check behind main
|
||||
git fetch origin main
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
git merge-base --is-ancestor origin/main pr-<PR> || echo "PR branch is behind main, run /preparepr"
|
||||
scripts/pr-merge verify <PR>
|
||||
source .local/prep.env
|
||||
```
|
||||
|
||||
If anything is failing or behind, stop and say to run `/preparepr`.
|
||||
`scripts/pr-merge` treats “no required checks configured” as acceptable (`[]`), but fails on any required `fail` or `pending`.
|
||||
|
||||
3. Merge PR and delete branch
|
||||
|
||||
If checks are still running, use `--auto` to queue the merge.
|
||||
3. Merge deterministically (wrapper-managed)
|
||||
|
||||
```sh
|
||||
# Check status first
|
||||
check_status=$(gh pr checks <PR> 2>&1)
|
||||
if echo "$check_status" | grep -q "pending\|queued"; then
|
||||
echo "Checks still running, using --auto to queue merge"
|
||||
gh pr merge <PR> --squash --delete-branch --auto
|
||||
echo "Merge queued. Monitor with: gh pr checks <PR> --watch"
|
||||
else
|
||||
gh pr merge <PR> --squash --delete-branch
|
||||
fi
|
||||
scripts/pr-merge run <PR>
|
||||
```
|
||||
|
||||
If merge fails, report the error and stop. Do not retry in a loop.
|
||||
If the PR needs changes beyond what `/preparepr` already did, stop and say to run `/preparepr` again.
|
||||
`scripts/pr-merge run` performs:
|
||||
|
||||
4. Get merge SHA
|
||||
- deterministic squash merge pinned to `PREP_HEAD_SHA`
|
||||
- reviewer merge author email selection with fallback candidates
|
||||
- one retry only when merge fails due to author-email validation
|
||||
- co-author trailers for PR author and reviewer
|
||||
- post-merge verification of both co-author trailers on commit message
|
||||
- PR comment retry (3 attempts), then comment URL extraction
|
||||
- cleanup after confirmed `MERGED`
|
||||
|
||||
4. Manual fallback (only if wrapper is unavailable)
|
||||
|
||||
```sh
|
||||
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
|
||||
echo "merge_sha=$merge_sha"
|
||||
scripts/pr merge-run <PR>
|
||||
```
|
||||
|
||||
5. Optional comment
|
||||
5. Cleanup
|
||||
|
||||
Use a literal multiline string or heredoc for newlines.
|
||||
|
||||
```sh
|
||||
gh pr comment <PR> -F - <<'EOF'
|
||||
Merged via squash.
|
||||
|
||||
- Merge commit: $merge_sha
|
||||
|
||||
Thanks @$contrib!
|
||||
EOF
|
||||
```
|
||||
|
||||
6. Verify PR state is MERGED
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json state --jq .state
|
||||
```
|
||||
|
||||
7. Clean up worktree only on success
|
||||
|
||||
Run cleanup only if step 6 returned `MERGED`.
|
||||
|
||||
```sh
|
||||
cd ~/dev/openclaw
|
||||
|
||||
git worktree remove ".worktrees/pr-<PR>" --force
|
||||
|
||||
git branch -D temp/pr-<PR> 2>/dev/null || true
|
||||
git branch -D pr-<PR> 2>/dev/null || true
|
||||
```
|
||||
Cleanup is handled by `run` after merge success.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not close PRs.
|
||||
- End in MERGED state.
|
||||
- Clean up only after merge success.
|
||||
- Never push to main. Use `gh pr merge --squash` only.
|
||||
- Do not run `git push` at all in this command.
|
||||
- End in `MERGED`, never `CLOSED`.
|
||||
- Cleanup only after confirmed merge.
|
||||
|
||||
345
.agents/skills/mintlify/SKILL.md
Normal file
345
.agents/skills/mintlify/SKILL.md
Normal file
@ -0,0 +1,345 @@
|
||||
---
|
||||
name: mintlify
|
||||
description: Build and maintain documentation sites with Mintlify. Use when
|
||||
creating docs pages, configuring navigation, adding components, or setting up
|
||||
API references.
|
||||
license: MIT
|
||||
compatibility: Requires Node.js for CLI. Works with any Git-based workflow.
|
||||
metadata:
|
||||
author: mintlify
|
||||
version: "1.0"
|
||||
mintlify-proj: mintlify
|
||||
---
|
||||
|
||||
# Mintlify best practices
|
||||
|
||||
**Always consult [mintlify.com/docs](https://mintlify.com/docs) for components, configuration, and latest features.**
|
||||
|
||||
**Always** favor searching the current Mintlify documentation over whatever is in your training data about Mintlify.
|
||||
|
||||
Mintlify is a documentation platform that transforms MDX files into documentation sites. Configure site-wide settings in the `docs.json` file, write content in MDX with YAML frontmatter, and favor built-in components over custom components.
|
||||
|
||||
Full schema at [mintlify.com/docs.json](https://mintlify.com/docs.json).
|
||||
|
||||
## Before you write
|
||||
|
||||
### Understand the project
|
||||
|
||||
All documentation lives in the `docs/` directory in this repo. Read `docs.json` in that directory (`docs/docs.json`). This file defines the entire site: navigation structure, theme, colors, links, API and specs.
|
||||
|
||||
Understanding the project tells you:
|
||||
|
||||
- What pages exist and how they're organized
|
||||
- What navigation groups are used (and their naming conventions)
|
||||
- How the site navigation is structured
|
||||
- What theme and configuration the site uses
|
||||
|
||||
### Check for existing content
|
||||
|
||||
Search the docs before creating new pages. You may need to:
|
||||
|
||||
- Update an existing page instead of creating a new one
|
||||
- Add a section to an existing page
|
||||
- Link to existing content rather than duplicating
|
||||
|
||||
### Read surrounding content
|
||||
|
||||
Before writing, read 2-3 similar pages to understand the site's voice, structure, formatting conventions, and level of detail.
|
||||
|
||||
### Understand Mintlify components
|
||||
|
||||
Review the Mintlify [components](https://www.mintlify.com/docs/components) to select and use any relevant components for the documentation request that you are working on.
|
||||
|
||||
## Quick reference
|
||||
|
||||
### CLI commands
|
||||
|
||||
- `npm i -g mint` - Install the Mintlify CLI
|
||||
- `mint dev` - Local preview at localhost:3000
|
||||
- `mint broken-links` - Check internal links
|
||||
- `mint a11y` - Check for accessibility issues in content
|
||||
- `mint rename` - Rename/move files and update references
|
||||
- `mint validate` - Validate documentation builds
|
||||
|
||||
### Required files
|
||||
|
||||
- `docs.json` - Site configuration (navigation, theme, integrations, etc.). See [global settings](https://mintlify.com/docs/settings/global) for all options.
|
||||
- `*.mdx` files - Documentation pages with YAML frontmatter
|
||||
|
||||
### Example file structure
|
||||
|
||||
```
|
||||
project/
|
||||
├── docs.json # Site configuration
|
||||
├── introduction.mdx
|
||||
├── quickstart.mdx
|
||||
├── guides/
|
||||
│ └── example.mdx
|
||||
├── openapi.yml # API specification
|
||||
├── images/ # Static assets
|
||||
│ └── example.png
|
||||
└── snippets/ # Reusable components
|
||||
└── component.jsx
|
||||
```
|
||||
|
||||
## Page frontmatter
|
||||
|
||||
Every page requires `title` in its frontmatter. Include `description` for SEO and navigation.
|
||||
|
||||
```yaml theme={null}
|
||||
---
|
||||
title: "Clear, descriptive title"
|
||||
description: "Concise summary for SEO and navigation."
|
||||
---
|
||||
```
|
||||
|
||||
Optional frontmatter fields:
|
||||
|
||||
- `sidebarTitle`: Short title for sidebar navigation.
|
||||
- `icon`: Lucide or Font Awesome icon name, URL, or file path.
|
||||
- `tag`: Label next to the page title in the sidebar (for example, "NEW").
|
||||
- `mode`: Page layout mode (`default`, `wide`, `custom`).
|
||||
- `keywords`: Array of terms related to the page content for local search and SEO.
|
||||
- Any custom YAML fields for use with personalization or conditional content.
|
||||
|
||||
## File conventions
|
||||
|
||||
- Match existing naming patterns in the directory
|
||||
- If there are no existing files or inconsistent file naming patterns, use kebab-case: `getting-started.mdx`, `api-reference.mdx`
|
||||
- Use root-relative paths without file extensions for internal links: `/getting-started/quickstart`
|
||||
- Do not use relative paths (`../`) or absolute URLs for internal pages
|
||||
- When you create a new page, add it to `docs.json` navigation or it won't appear in the sidebar
|
||||
|
||||
## Organize content
|
||||
|
||||
When a user asks about anything related to site-wide configurations, start by understanding the [global settings](https://www.mintlify.com/docs/organize/settings). See if a setting in the `docs.json` file can be updated to achieve what the user wants.
|
||||
|
||||
### Navigation
|
||||
|
||||
The `navigation` property in `docs.json` controls site structure. Choose one primary pattern at the root level, then nest others within it.
|
||||
|
||||
**Choose your primary pattern:**
|
||||
|
||||
| Pattern | When to use |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| **Groups** | Default. Single audience, straightforward hierarchy |
|
||||
| **Tabs** | Distinct sections with different audiences (Guides vs API Reference) or content types |
|
||||
| **Anchors** | Want persistent section links at sidebar top. Good for separating docs from external resources |
|
||||
| **Dropdowns** | Multiple doc sections users switch between, but not distinct enough for tabs |
|
||||
| **Products** | Multi-product company with separate documentation per product |
|
||||
| **Versions** | Maintaining docs for multiple API/product versions simultaneously |
|
||||
| **Languages** | Localized content |
|
||||
|
||||
**Within your primary pattern:**
|
||||
|
||||
- **Groups** - Organize related pages. Can nest groups within groups, but keep hierarchy shallow
|
||||
- **Menus** - Add dropdown navigation within tabs for quick jumps to specific pages
|
||||
- **`expanded: false`** - Collapse nested groups by default. Use for reference sections users browse selectively
|
||||
- **`openapi`** - Auto-generate pages from OpenAPI spec. Add at group/tab level to inherit
|
||||
|
||||
**Common combinations:**
|
||||
|
||||
- Tabs containing groups (most common for docs with API reference)
|
||||
- Products containing tabs (multi-product SaaS)
|
||||
- Versions containing tabs (versioned API docs)
|
||||
- Anchors containing groups (simple docs with external resource links)
|
||||
|
||||
### Links and paths
|
||||
|
||||
- **Internal links:** Root-relative, no extension: `/getting-started/quickstart`
|
||||
- **Images:** Store in `/images`, reference as `/images/example.png`
|
||||
- **External links:** Use full URLs, they open in new tabs automatically
|
||||
|
||||
## Customize docs sites
|
||||
|
||||
**What to customize where:**
|
||||
|
||||
- **Brand colors, fonts, logo** → `docs.json`. See [global settings](https://mintlify.com/docs/settings/global)
|
||||
- **Component styling, layout tweaks** → `custom.css` at project root
|
||||
- **Dark mode** → Enabled by default. Only disable with `"appearance": "light"` in `docs.json` if brand requires it
|
||||
|
||||
Start with `docs.json`. Only add `custom.css` when you need styling that config doesn't support.
|
||||
|
||||
## Write content
|
||||
|
||||
### Components
|
||||
|
||||
The [components overview](https://mintlify.com/docs/components) organizes all components by purpose: structure content, draw attention, show/hide content, document APIs, link to pages, and add visual context. Start there to find the right component.
|
||||
|
||||
**Common decision points:**
|
||||
|
||||
| Need | Use |
|
||||
| -------------------------- | ----------------------- |
|
||||
| Hide optional details | `<Accordion>` |
|
||||
| Long code examples | `<Expandable>` |
|
||||
| User chooses one option | `<Tabs>` |
|
||||
| Linked navigation cards | `<Card>` in `<Columns>` |
|
||||
| Sequential instructions | `<Steps>` |
|
||||
| Code in multiple languages | `<CodeGroup>` |
|
||||
| API parameters | `<ParamField>` |
|
||||
| API response fields | `<ResponseField>` |
|
||||
|
||||
**Callouts by severity:**
|
||||
|
||||
- `<Note>` - Supplementary info, safe to skip
|
||||
- `<Info>` - Helpful context such as permissions
|
||||
- `<Tip>` - Recommendations or best practices
|
||||
- `<Warning>` - Potentially destructive actions
|
||||
- `<Check>` - Success confirmation
|
||||
|
||||
### Reusable content
|
||||
|
||||
**When to use snippets:**
|
||||
|
||||
- Exact content appears on more than one page
|
||||
- Complex components you want to maintain in one place
|
||||
- Shared content across teams/repos
|
||||
|
||||
**When NOT to use snippets:**
|
||||
|
||||
- Slight variations needed per page (leads to complex props)
|
||||
|
||||
Import snippets with `import { Component } from "/path/to/snippet-name.jsx"`.
|
||||
|
||||
## Writing standards
|
||||
|
||||
### Voice and structure
|
||||
|
||||
- Second-person voice ("you")
|
||||
- Active voice, direct language
|
||||
- Sentence case for headings ("Getting started", not "Getting Started")
|
||||
- Sentence case for code block titles ("Expandable example", not "Expandable Example")
|
||||
- Lead with context: explain what something is before how to use it
|
||||
- Prerequisites at the start of procedural content
|
||||
|
||||
### What to avoid
|
||||
|
||||
**Never use:**
|
||||
|
||||
- Marketing language ("powerful", "seamless", "robust", "cutting-edge")
|
||||
- Filler phrases ("it's important to note", "in order to")
|
||||
- Excessive conjunctions ("moreover", "furthermore", "additionally")
|
||||
- Editorializing ("obviously", "simply", "just", "easily")
|
||||
|
||||
**Watch for AI-typical patterns:**
|
||||
|
||||
- Overly formal or stilted phrasing
|
||||
- Unnecessary repetition of concepts
|
||||
- Generic introductions that don't add value
|
||||
- Concluding summaries that restate what was just said
|
||||
|
||||
### Formatting
|
||||
|
||||
- All code blocks must have language tags
|
||||
- All images and media must have descriptive alt text
|
||||
- Use bold and italics only when they serve the reader's understanding--never use text styling just for decoration
|
||||
- No decorative formatting or emoji
|
||||
|
||||
### Code examples
|
||||
|
||||
- Keep examples simple and practical
|
||||
- Use realistic values (not "foo" or "bar")
|
||||
- One clear example is better than multiple variations
|
||||
- Test that code works before including it
|
||||
|
||||
## Document APIs
|
||||
|
||||
**Choose your approach:**
|
||||
|
||||
- **Have an OpenAPI spec?** → Add to `docs.json` with `"openapi": ["openapi.yaml"]`. Pages auto-generate. Reference in navigation as `GET /endpoint`
|
||||
- **No spec?** → Write endpoints manually with `api: "POST /users"` in frontmatter. More work but full control
|
||||
- **Hybrid** → Use OpenAPI for most endpoints, manual pages for complex workflows
|
||||
|
||||
Encourage users to generate endpoint pages from an OpenAPI spec. It is the most efficient and easiest to maintain option.
|
||||
|
||||
## Deploy
|
||||
|
||||
Mintlify deploys automatically when changes are pushed to the connected Git repository.
|
||||
|
||||
**What agents can configure:**
|
||||
|
||||
- **Redirects** → Add to `docs.json` with `"redirects": [{"source": "/old", "destination": "/new"}]`
|
||||
- **SEO indexing** → Control with `"seo": {"indexing": "all"}` to include hidden pages in search
|
||||
|
||||
**Requires dashboard setup (human task):**
|
||||
|
||||
- Custom domains and subdomains
|
||||
- Preview deployment settings
|
||||
- DNS configuration
|
||||
|
||||
For `/docs` subpath hosting with Vercel or Cloudflare, agents can help configure rewrite rules. See [/docs subpath](https://mintlify.com/docs/deploy/vercel).
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Understand the task
|
||||
|
||||
Identify what needs to be documented, which pages are affected, and what the reader should accomplish afterward. If any of these are unclear, ask.
|
||||
|
||||
### 2. Research
|
||||
|
||||
- Read `docs/docs.json` to understand the site structure
|
||||
- Search existing docs for related content
|
||||
- Read similar pages to match the site's style
|
||||
|
||||
### 3. Plan
|
||||
|
||||
- Synthesize what the reader should accomplish after reading the docs and the current content
|
||||
- Propose any updates or new content
|
||||
- Verify that your proposed changes will help readers be successful
|
||||
|
||||
### 4. Write
|
||||
|
||||
- Start with the most important information
|
||||
- Keep sections focused and scannable
|
||||
- Use components appropriately (don't overuse them)
|
||||
- Mark anything uncertain with a TODO comment:
|
||||
|
||||
```mdx theme={null}
|
||||
{/* TODO: Verify the default timeout value */}
|
||||
```
|
||||
|
||||
### 5. Update navigation
|
||||
|
||||
If you created a new page, add it to the appropriate group in `docs.json`.
|
||||
|
||||
### 6. Verify
|
||||
|
||||
Before submitting:
|
||||
|
||||
- [ ] Frontmatter includes title and description
|
||||
- [ ] All code blocks have language tags
|
||||
- [ ] Internal links use root-relative paths without file extensions
|
||||
- [ ] New pages are added to `docs.json` navigation
|
||||
- [ ] Content matches the style of surrounding pages
|
||||
- [ ] No marketing language or filler phrases
|
||||
- [ ] TODOs are clearly marked for anything uncertain
|
||||
- [ ] Run `mint broken-links` to check links
|
||||
- [ ] Run `mint validate` to find any errors
|
||||
|
||||
## Edge cases
|
||||
|
||||
### Migrations
|
||||
|
||||
If a user asks about migrating to Mintlify, ask if they are using ReadMe or Docusaurus. If they are, use the [@mintlify/scraping](https://www.npmjs.com/package/@mintlify/scraping) CLI to migrate content. If they are using a different platform to host their documentation, help them manually convert their content to MDX pages using Mintlify components.
|
||||
|
||||
### Hidden pages
|
||||
|
||||
Any page that is not included in the `docs.json` navigation is hidden. Use hidden pages for content that should be accessible by URL or indexed for the assistant or search, but not discoverable through the sidebar navigation.
|
||||
|
||||
### Exclude pages
|
||||
|
||||
The `.mintignore` file is used to exclude files from a documentation repository from being processed.
|
||||
|
||||
## Common gotchas
|
||||
|
||||
1. **Component imports** - JSX components need explicit import, MDX components don't
|
||||
2. **Frontmatter required** - Every MDX file needs `title` at minimum
|
||||
3. **Code block language** - Always specify language identifier
|
||||
4. **Never use `mint.json`** - `mint.json` is deprecated. Only ever use `docs.json`
|
||||
|
||||
## Resources
|
||||
|
||||
- [Documentation](https://mintlify.com/docs)
|
||||
- [Configuration schema](https://mintlify.com/docs.json)
|
||||
- [Feature requests](https://github.com/orgs/mintlify/discussions/categories/feature-requests)
|
||||
- [Bugs and feedback](https://github.com/orgs/mintlify/discussions/categories/bugs-feedback)
|
||||
@ -1,248 +1,131 @@
|
||||
---
|
||||
name: prepare-pr
|
||||
description: Prepare a GitHub PR for merge by rebasing onto main, fixing review findings, running gates, committing fixes, and pushing to the PR head branch. Use after /reviewpr. Never merge or push to main.
|
||||
description: Script-first PR preparation with structured findings resolution, deterministic push safety, and explicit gate execution.
|
||||
---
|
||||
|
||||
# Prepare PR
|
||||
|
||||
## Overview
|
||||
|
||||
Prepare a PR branch for merge with review fixes, green gates, and an updated head branch.
|
||||
Prepare the PR head branch for merge after `/review-pr`.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
- If missing, use `.local/pr-meta.env` if present in the PR worktree.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never push to `main` or `origin/main`. Push only to the PR head branch.
|
||||
- Never run `git push` without specifying remote and branch explicitly. Do not run bare `git push`.
|
||||
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
|
||||
- Never push to `main`.
|
||||
- Only push to PR head with explicit `--force-with-lease` against known head SHA.
|
||||
- Do not run `git clean -fdx`.
|
||||
- Do not run `git add -A` or `git add .`. Stage only specific files changed.
|
||||
- Wrappers are cwd-agnostic; run from repo root or PR worktree.
|
||||
|
||||
## Execution Rule
|
||||
## Execution Contract
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs.
|
||||
|
||||
## Known Footguns
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
|
||||
- Do not run `git clean -fdx`.
|
||||
- Do not run `git add -A` or `git add .`.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Rebase PR commits onto `origin/main`.
|
||||
- Fix all BLOCKER and IMPORTANT items from `.local/review.md`.
|
||||
- Run gates and pass.
|
||||
- Commit prep changes.
|
||||
- Push the updated HEAD back to the PR head branch.
|
||||
- Write `.local/prep.md` with a prep summary.
|
||||
- Output exactly: `PR is ready for /mergepr`.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all prep steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all prep work.
|
||||
1. Run setup:
|
||||
|
||||
```sh
|
||||
cd ~/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
scripts/pr-prepare init <PR>
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
2. Resolve findings from structured review:
|
||||
|
||||
## Load Review Findings (Mandatory)
|
||||
- `.local/review.json` is mandatory.
|
||||
- Resolve all `BLOCKER` and `IMPORTANT` items.
|
||||
|
||||
3. Commit with required subject format and validate it.
|
||||
|
||||
4. Run gates via wrapper.
|
||||
|
||||
5. Push via wrapper (includes pre-push remote verification, one automatic lease-retry path, and post-push API propagation retry).
|
||||
|
||||
Optional one-shot path:
|
||||
|
||||
```sh
|
||||
if [ -f .local/review.md ]; then
|
||||
echo "Found review findings from /reviewpr"
|
||||
else
|
||||
echo "Missing .local/review.md. Run /reviewpr first and save findings."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read it
|
||||
sed -n '1,200p' .local/review.md
|
||||
scripts/pr-prepare run <PR>
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta (author, head branch, head repo URL)
|
||||
1. Setup and artifacts
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository,body --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
scripts/pr-prepare init <PR>
|
||||
|
||||
ls -la .local/review.md .local/review.json .local/pr-meta.env .local/prep-context.env
|
||||
jq . .local/review.json >/dev/null
|
||||
```
|
||||
|
||||
2. Fetch the PR branch tip into a local ref
|
||||
2. Resolve required findings
|
||||
|
||||
List required items:
|
||||
|
||||
```sh
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
jq -r '.findings[] | select(.severity=="BLOCKER" or .severity=="IMPORTANT") | "- [\(.severity)] \(.id): \(.title) => \(.fix)"' .local/review.json
|
||||
```
|
||||
|
||||
3. Rebase PR commits onto latest main
|
||||
Fix all required findings. Keep scope tight.
|
||||
|
||||
3. Update changelog/docs when required
|
||||
|
||||
```sh
|
||||
# Move worktree to the PR tip first
|
||||
git reset --hard pr-<PR>
|
||||
|
||||
# Rebase onto current main
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
jq -r '.changelog' .local/review.json
|
||||
jq -r '.docs' .local/review.json
|
||||
```
|
||||
|
||||
If conflicts happen:
|
||||
4. Commit scoped changes
|
||||
|
||||
- Resolve each conflicted file.
|
||||
- Run `git add <resolved_file>` for each file.
|
||||
- Run `git rebase --continue`.
|
||||
Required commit subject format:
|
||||
|
||||
If the rebase gets confusing or you resolve conflicts 3 or more times, stop and report.
|
||||
- `fix: <summary> (openclaw#<PR>) thanks @<pr-author>`
|
||||
|
||||
4. Fix issues from `.local/review.md`
|
||||
|
||||
- Fix all BLOCKER and IMPORTANT items.
|
||||
- NITs are optional.
|
||||
- Keep scope tight.
|
||||
|
||||
Keep a running log in `.local/prep.md`:
|
||||
|
||||
- List which review items you fixed.
|
||||
- List which files you touched.
|
||||
- Note behavior changes.
|
||||
|
||||
5. Update `CHANGELOG.md` if flagged in review
|
||||
|
||||
Check `.local/review.md` section H for guidance.
|
||||
If flagged and user-facing:
|
||||
|
||||
- Check if `CHANGELOG.md` exists.
|
||||
Use explicit file list:
|
||||
|
||||
```sh
|
||||
ls CHANGELOG.md 2>/dev/null
|
||||
source .local/pr-meta.env
|
||||
scripts/committer "fix: <summary> (openclaw#$PR_NUMBER) thanks @$PR_AUTHOR" <file1> <file2> ...
|
||||
```
|
||||
|
||||
- Follow existing format.
|
||||
- Add a concise entry with PR number and contributor.
|
||||
|
||||
6. Update docs if flagged in review
|
||||
|
||||
Check `.local/review.md` section G for guidance.
|
||||
If flagged, update only docs related to the PR changes.
|
||||
|
||||
7. Commit prep fixes
|
||||
|
||||
Stage only specific files:
|
||||
Validate commit subject:
|
||||
|
||||
```sh
|
||||
git add <file1> <file2> ...
|
||||
scripts/pr-prepare validate-commit <PR>
|
||||
```
|
||||
|
||||
Preferred commit tool:
|
||||
5. Run gates
|
||||
|
||||
```sh
|
||||
committer "fix: <summary> (#<PR>) (thanks @$contrib)" <changed files>
|
||||
scripts/pr-prepare gates <PR>
|
||||
```
|
||||
|
||||
If `committer` is not found:
|
||||
6. Push safely to PR head
|
||||
|
||||
```sh
|
||||
git commit -m "fix: <summary> (#<PR>) (thanks @$contrib)"
|
||||
scripts/pr-prepare push <PR>
|
||||
```
|
||||
|
||||
8. Run full gates before pushing
|
||||
This push step includes:
|
||||
|
||||
- robust fork remote resolution from owner/name,
|
||||
- pre-push remote SHA verification,
|
||||
- one automatic rebase + gate rerun + retry if lease push fails,
|
||||
- post-push PR-head propagation retry,
|
||||
- idempotent behavior when local prep HEAD is already on the PR head,
|
||||
- post-push SHA verification and `.local/prep.env` generation.
|
||||
|
||||
7. Verify handoff artifacts
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
pnpm check
|
||||
pnpm test
|
||||
ls -la .local/prep.md .local/prep.env
|
||||
```
|
||||
|
||||
Require all to pass. If something fails, fix, commit, and rerun. Allow at most 3 fix and rerun cycles. If gates still fail after 3 attempts, stop and report the failures. Do not loop indefinitely.
|
||||
8. Output
|
||||
|
||||
9. Push updates back to the PR head branch
|
||||
|
||||
```sh
|
||||
# Ensure remote for PR head exists
|
||||
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
|
||||
|
||||
# Use force with lease after rebase
|
||||
# Double check: $head must NOT be "main" or "master"
|
||||
echo "Pushing to branch: $head"
|
||||
if [ "$head" = "main" ] || [ "$head" = "master" ]; then
|
||||
echo "ERROR: head branch is main/master. This is wrong. Stopping."
|
||||
exit 1
|
||||
fi
|
||||
git push --force-with-lease prhead HEAD:$head
|
||||
```
|
||||
|
||||
10. Verify PR is not behind main (Mandatory)
|
||||
|
||||
```sh
|
||||
git fetch origin main
|
||||
git fetch origin pull/<PR>/head:pr-<PR>-verify --force
|
||||
git merge-base --is-ancestor origin/main pr-<PR>-verify && echo "PR is up to date with main" || echo "ERROR: PR is still behind main, rebase again"
|
||||
git branch -D pr-<PR>-verify 2>/dev/null || true
|
||||
```
|
||||
|
||||
If still behind main, repeat steps 2 through 9.
|
||||
|
||||
11. Write prep summary artifacts (Mandatory)
|
||||
|
||||
Update `.local/prep.md` with:
|
||||
|
||||
- Current HEAD sha from `git rev-parse HEAD`.
|
||||
- Short bullet list of changes.
|
||||
- Gate results.
|
||||
- Push confirmation.
|
||||
- Rebase verification result.
|
||||
|
||||
Create or overwrite `.local/prep.md` and verify it exists and is non-empty:
|
||||
|
||||
```sh
|
||||
git rev-parse HEAD
|
||||
ls -la .local/prep.md
|
||||
wc -l .local/prep.md
|
||||
```
|
||||
|
||||
12. Output
|
||||
|
||||
Include a diff stat summary:
|
||||
|
||||
```sh
|
||||
git diff --stat origin/main..HEAD
|
||||
git diff --shortstat origin/main..HEAD
|
||||
```
|
||||
|
||||
Report totals: X files changed, Y insertions(+), Z deletions(-).
|
||||
|
||||
If gates passed and push succeeded, print exactly:
|
||||
|
||||
```
|
||||
PR is ready for /mergepr
|
||||
```
|
||||
|
||||
Otherwise, list remaining failures and stop.
|
||||
- Summarize resolved findings and gate results.
|
||||
- Print exactly: `PR is ready for /merge-pr`.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not delete the worktree on success. `/mergepr` may reuse it.
|
||||
- Do not run `gh pr merge`.
|
||||
- Never push to main. Only push to the PR head branch.
|
||||
- Run and pass all gates before pushing.
|
||||
- Do not run `gh pr merge` in this skill.
|
||||
- Do not delete worktree.
|
||||
|
||||
@ -1,228 +1,141 @@
|
||||
---
|
||||
name: review-pr
|
||||
description: Review-only GitHub pull request analysis with the gh CLI. Use when asked to review a PR, provide structured feedback, or assess readiness to land. Do not merge, push, or make code changes you intend to keep.
|
||||
description: Script-first review-only GitHub pull request analysis. Use for deterministic PR review with structured findings handoff to /prepare-pr.
|
||||
---
|
||||
|
||||
# Review PR
|
||||
|
||||
## Overview
|
||||
|
||||
Perform a thorough review-only PR assessment and return a structured recommendation on readiness for /preparepr.
|
||||
Perform a read-only review and produce both human and machine-readable outputs.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, always ask. Never auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
- If missing, always ask.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never push to `main` or `origin/main`, not during review, not ever.
|
||||
- Do not run `git push` at all during review. Treat review as read only.
|
||||
- Do not stop or kill the gateway. Do not run gateway stop commands. Do not kill processes on port 18792.
|
||||
- Never push, merge, or modify code intended to keep.
|
||||
- Work only in `.worktrees/pr-<PR>`.
|
||||
|
||||
## Execution Rule
|
||||
## Execution Contract
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs, not a plan.
|
||||
|
||||
## Known Failure Modes
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
|
||||
- Do not stop after printing the checklist. That is not completion.
|
||||
|
||||
## Writing Style for Output
|
||||
|
||||
- Write casual and direct.
|
||||
- Avoid em dashes and en dashes. Use commas or separate sentences.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Run the commands in the worktree and inspect the PR directly.
|
||||
- Produce the structured review sections A through J.
|
||||
- Save the full review to `.local/review.md` inside the worktree.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all review steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all review work.
|
||||
1. Run wrapper setup:
|
||||
|
||||
```sh
|
||||
cd ~/dev/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
git fetch origin main
|
||||
|
||||
# Reuse existing worktree if it exists, otherwise create new
|
||||
if [ -d "$WORKTREE_DIR" ]; then
|
||||
cd "$WORKTREE_DIR"
|
||||
git checkout temp/pr-<PR> 2>/dev/null || git checkout -b temp/pr-<PR>
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
else
|
||||
git worktree add "$WORKTREE_DIR" -b temp/pr-<PR> origin/main
|
||||
cd "$WORKTREE_DIR"
|
||||
fi
|
||||
|
||||
# Create local scratch space that persists across /reviewpr to /preparepr to /mergepr
|
||||
mkdir -p .local
|
||||
scripts/pr-review <PR>
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
Start on `origin/main` so you can check for existing implementations before looking at PR code.
|
||||
2. Use explicit branch mode switches:
|
||||
|
||||
- Main baseline mode: `scripts/pr review-checkout-main <PR>`
|
||||
- PR-head mode: `scripts/pr review-checkout-pr <PR>`
|
||||
|
||||
3. Before writing review outputs, run branch guard:
|
||||
|
||||
```sh
|
||||
scripts/pr review-guard <PR>
|
||||
```
|
||||
|
||||
4. Write both outputs:
|
||||
|
||||
- `.local/review.md` with sections A through J.
|
||||
- `.local/review.json` with structured findings.
|
||||
|
||||
5. Validate artifacts semantically:
|
||||
|
||||
```sh
|
||||
scripts/pr review-validate-artifacts <PR>
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta and context
|
||||
1. Setup and metadata
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length,body}'
|
||||
scripts/pr-review <PR>
|
||||
ls -la .local/pr-meta.json .local/pr-meta.env .local/review-context.env .local/review-mode.env
|
||||
```
|
||||
|
||||
2. Check if this already exists in main before looking at the PR branch
|
||||
|
||||
- Identify the core feature or fix from the PR title and description.
|
||||
- Search for existing implementations using keywords from the PR title, changed file paths, and function or component names from the diff.
|
||||
2. Existing implementation check on main
|
||||
|
||||
```sh
|
||||
# Use keywords from the PR title and changed files
|
||||
rg -n "<keyword_from_pr_title>" -S src packages apps ui || true
|
||||
rg -n "<function_or_component_name>" -S src packages apps ui || true
|
||||
|
||||
git log --oneline --all --grep="<keyword_from_pr_title>" | head -20
|
||||
scripts/pr review-checkout-main <PR>
|
||||
rg -n "<keyword>" -S src extensions apps || true
|
||||
git log --oneline --all --grep "<keyword>" | head -20
|
||||
```
|
||||
|
||||
If it already exists, call it out as a BLOCKER or at least IMPORTANT.
|
||||
|
||||
3. Claim the PR
|
||||
|
||||
Assign yourself so others know someone is reviewing. Skip if the PR looks like spam or is a draft you plan to recommend closing.
|
||||
3. Claim PR
|
||||
|
||||
```sh
|
||||
gh_user=$(gh api user --jq .login)
|
||||
gh pr edit <PR> --add-assignee "$gh_user"
|
||||
gh pr edit <PR> --add-assignee "$gh_user" || echo "Could not assign reviewer, continuing"
|
||||
```
|
||||
|
||||
4. Read the PR description carefully
|
||||
|
||||
Use the body from step 1. Summarize goal, scope, and missing context.
|
||||
|
||||
5. Read the diff thoroughly
|
||||
|
||||
Minimum:
|
||||
4. Read PR description and diff
|
||||
|
||||
```sh
|
||||
scripts/pr review-checkout-pr <PR>
|
||||
gh pr diff <PR>
|
||||
|
||||
source .local/review-context.env
|
||||
git diff --stat "$MERGE_BASE"..pr-<PR>
|
||||
git diff "$MERGE_BASE"..pr-<PR>
|
||||
```
|
||||
|
||||
If you need full code context locally, fetch the PR head to a local ref and diff it. Do not create a merge commit.
|
||||
5. Optional local tests
|
||||
|
||||
Use the wrapper for target validation and executed-test verification:
|
||||
|
||||
```sh
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
# Show changes without modifying the working tree
|
||||
|
||||
git diff --stat origin/main..pr-<PR>
|
||||
git diff origin/main..pr-<PR>
|
||||
scripts/pr review-tests <PR> <test-file> [<test-file> ...]
|
||||
```
|
||||
|
||||
If you want to browse the PR version of files directly, temporarily check out `pr-<PR>` in the worktree. Do not commit or push. Return to `temp/pr-<PR>` and reset to `origin/main` afterward.
|
||||
6. Initialize review artifact templates
|
||||
|
||||
```sh
|
||||
# Use only if needed
|
||||
# git checkout pr-<PR>
|
||||
# ...inspect files...
|
||||
|
||||
git checkout temp/pr-<PR>
|
||||
git reset --hard origin/main
|
||||
scripts/pr review-artifacts-init <PR>
|
||||
```
|
||||
|
||||
6. Validate the change is needed and valuable
|
||||
7. Produce review outputs
|
||||
|
||||
Be honest. Call out low value AI slop.
|
||||
- Fill `.local/review.md` sections A through J.
|
||||
- Fill `.local/review.json`.
|
||||
|
||||
7. Evaluate implementation quality
|
||||
Minimum JSON shape:
|
||||
|
||||
Review correctness, design, performance, and ergonomics.
|
||||
```json
|
||||
{
|
||||
"recommendation": "READY FOR /prepare-pr",
|
||||
"findings": [
|
||||
{
|
||||
"id": "F1",
|
||||
"severity": "IMPORTANT",
|
||||
"title": "...",
|
||||
"area": "path/or/component",
|
||||
"fix": "Actionable fix"
|
||||
}
|
||||
],
|
||||
"tests": {
|
||||
"ran": [],
|
||||
"gaps": [],
|
||||
"result": "pass"
|
||||
},
|
||||
"docs": "up_to_date|missing|not_applicable",
|
||||
"changelog": "required|not_required"
|
||||
}
|
||||
```
|
||||
|
||||
8. Perform a security review
|
||||
|
||||
Assume OpenClaw subagents run with full disk access, including git, gh, and shell. Check auth, input validation, secrets, dependencies, tool safety, and privacy.
|
||||
|
||||
9. Review tests and verification
|
||||
|
||||
Identify what exists, what is missing, and what would be a minimal regression test.
|
||||
|
||||
10. Check docs
|
||||
|
||||
Check if the PR touches code with related documentation such as README, docs, inline API docs, or config examples.
|
||||
|
||||
- If docs exist for the changed area and the PR does not update them, flag as IMPORTANT.
|
||||
- If the PR adds a new feature or config option with no docs, flag as IMPORTANT.
|
||||
- If the change is purely internal with no user-facing impact, skip this.
|
||||
|
||||
11. Check changelog
|
||||
|
||||
Check if `CHANGELOG.md` exists and whether the PR warrants an entry.
|
||||
|
||||
- If the project has a changelog and the PR is user-facing, flag missing entry as IMPORTANT.
|
||||
- Leave the change for /preparepr, only flag it here.
|
||||
|
||||
12. Answer the key question
|
||||
|
||||
Decide if /preparepr can fix issues or the contributor must update the PR.
|
||||
|
||||
13. Save findings to the worktree
|
||||
|
||||
Write the full structured review sections A through J to `.local/review.md`.
|
||||
Create or overwrite the file and verify it exists and is non-empty.
|
||||
8. Guard + validate before final output
|
||||
|
||||
```sh
|
||||
ls -la .local/review.md
|
||||
wc -l .local/review.md
|
||||
scripts/pr review-guard <PR>
|
||||
scripts/pr review-validate-artifacts <PR>
|
||||
```
|
||||
|
||||
14. Output the structured review
|
||||
|
||||
Produce a review that matches what you saved to `.local/review.md`.
|
||||
|
||||
A) TL;DR recommendation
|
||||
|
||||
- One of: READY FOR /preparepr | NEEDS WORK | NEEDS DISCUSSION | NOT USEFUL (CLOSE)
|
||||
- 1 to 3 sentences.
|
||||
|
||||
B) What changed
|
||||
|
||||
C) What is good
|
||||
|
||||
D) Security findings
|
||||
|
||||
E) Concerns or questions (actionable)
|
||||
|
||||
- Numbered list.
|
||||
- Mark each item as BLOCKER, IMPORTANT, or NIT.
|
||||
- For each, point to file or area and propose a concrete fix.
|
||||
|
||||
F) Tests
|
||||
|
||||
G) Docs status
|
||||
|
||||
- State if related docs are up to date, missing, or not applicable.
|
||||
|
||||
H) Changelog
|
||||
|
||||
- State if `CHANGELOG.md` needs an entry and which category.
|
||||
|
||||
I) Follow ups (optional)
|
||||
|
||||
J) Suggested PR comment (optional)
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not delete the worktree after review.
|
||||
- Review only, do not merge, do not push.
|
||||
- Keep review read-only.
|
||||
- Do not delete worktree.
|
||||
- Use merge-base scoped diff for local context to avoid stale branch drift.
|
||||
|
||||
665
.cursor/plans/dench_filesystem_crm_integration.plan.md
Normal file
665
.cursor/plans/dench_filesystem_crm_integration.plan.md
Normal file
@ -0,0 +1,665 @@
|
||||
---
|
||||
name: Dench Filesystem CRM Integration
|
||||
overview: Replace Dench's slow tool-by-tool CRM agent with a filesystem-first architecture where OpenClaw manages a DuckDB database and markdown documents in a dedicated workspace folder, synced via S3, and surfaced in Dench's sidebar.
|
||||
todos:
|
||||
- id: inject-skill-infra
|
||||
content: "Phase 1: Add `inject: true` skill metadata support to OpenClaw (types.ts, workspace.ts, system-prompt.ts, run.ts)"
|
||||
status: pending
|
||||
- id: dench-skill
|
||||
content: "Phase 2: Create skills/dench/SKILL.md with DuckDB schema, SQL patterns, CRM patterns, document management instructions"
|
||||
status: pending
|
||||
- id: s3-sync-script
|
||||
content: "Phase 3: Create S3 sync script and sandbox startup hook for dench workspace persistence"
|
||||
status: pending
|
||||
- id: dench-sidebar
|
||||
content: "Phase 4 (DEFERRED): Add workspace data source to Dench sidebar (tRPC endpoints + buildKnowledgeTree merge)"
|
||||
status: pending
|
||||
- id: lambda-sync
|
||||
content: "Phase 4b (DEFERRED): S3-to-PostgreSQL Lambda sync for fast sidebar queries (or simpler: direct DuckDB read from S3)"
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Dench Filesystem-First CRM via OpenClaw
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The current Dench CRM agent uses 15+ individual tRPC-backed tools (createObjectTool, createFieldTool, createEntryTool, searchEntriesTool, etc.) that each make a Prisma call. This is slow -- the agent often needs 10+ tool calls for a single user request.
|
||||
|
||||
The new architecture gives OpenClaw a `dench/` workspace folder with a DuckDB database and markdown files. The agent generates SQL directly via `exec` (duckdb CLI) and writes documents as `.md` files. S3 syncs this data between sandbox sessions, and Dench's sidebar reads from S3/PostgreSQL.
|
||||
|
||||
**Why DuckDB over SQLite:**
|
||||
|
||||
- Native PIVOT/UNPIVOT -- essential for the EAV (Entity-Attribute-Value) pattern used by custom fields; every "show me entries as a table" query needs pivot
|
||||
- PostgreSQL-compatible SQL dialect -- matches Dench's Supabase/Postgres, so generated SQL is portable
|
||||
- Native JSON type -- clean handling of `enum_values`, `enum_colors`, field mappings (no JSON1 extension needed)
|
||||
- Built-in CSV/Parquet import/export with auto-detection -- bulk CRM operations ("import 500 leads from CSV")
|
||||
- FTS extension -- full-text search across entry fields
|
||||
- `generate_series` + macros -- nanoid 32 generation in pure SQL
|
||||
- ~50-100ms startup overhead is acceptable given agent tool-call overhead is already 200-500ms
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph sandbox [OpenClaw Sandbox]
|
||||
agent[OpenClaw Agent]
|
||||
skill[Dench Skill - always in context]
|
||||
ctx[dench/workspace_context.yaml]
|
||||
db[dench/workspace.duckdb]
|
||||
docs[dench/documents/*.md]
|
||||
agent --> skill
|
||||
agent -->|"read-only context"| ctx
|
||||
agent -->|"exec: duckdb"| db
|
||||
agent -->|"write/edit"| docs
|
||||
end
|
||||
|
||||
subgraph s3layer [Persistence Layer]
|
||||
s3["S3: dench/{orgId}/"]
|
||||
end
|
||||
|
||||
subgraph denchApp [Dench Web App]
|
||||
sidebar[App Sidebar]
|
||||
api[tRPC API]
|
||||
pg[PostgreSQL - fs_files sync]
|
||||
end
|
||||
|
||||
db -->|sync on save| s3
|
||||
docs -->|sync on save| s3
|
||||
s3 -->|Lambda trigger| pg
|
||||
pg --> api --> sidebar
|
||||
s3 -->|download on sandbox start| db
|
||||
s3 -->|download on sandbox start| docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Always-In-Context Skill Infrastructure (OpenClaw)
|
||||
|
||||
Currently, all skills are lazy-loaded: only name + description appear in the system prompt, and the agent must `read` the SKILL.md to get full instructions. For the Dench CRM skill, we need the full content injected automatically.
|
||||
|
||||
**Approach:** Add an `inject: true` flag to skill metadata. When set, the skill's full content is included in the system prompt alongside bootstrap files (in the "Project Context" section), not in the lazy-loaded skills list.
|
||||
|
||||
**Files to modify:**
|
||||
|
||||
- [src/agents/skills/types.ts](src/agents/skills/types.ts) -- Add `inject?: boolean` to `OpenClawSkillMetadata`
|
||||
- [src/agents/skills/workspace.ts](src/agents/skills/workspace.ts) -- In `buildWorkspaceSkillSnapshot()`, separate injected skills from lazy-loaded skills. Return injected skill contents alongside the prompt.
|
||||
- [src/agents/system-prompt.ts](src/agents/system-prompt.ts) -- Accept injected skill content and include it in the "Project Context" / "Workspace Files" section (similar to how bootstrap files like AGENTS.md are included via `contextFiles`)
|
||||
- [src/agents/pi-embedded-runner/run.ts](src/agents/pi-embedded-runner/run.ts) -- Pass injected skill content through to the system prompt builder
|
||||
|
||||
**Key change in `buildWorkspaceSkillSnapshot`:**
|
||||
|
||||
```typescript
|
||||
// New return type addition
|
||||
export type SkillSnapshot = {
|
||||
prompt: string; // lazy-loaded skills prompt (XML)
|
||||
injectedContent?: string; // always-in-context skill content (concatenated)
|
||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Dench CRM Skill (OpenClaw)
|
||||
|
||||
Create `skills/dench/SKILL.md` with:
|
||||
|
||||
- Full DuckDB schema reference
|
||||
- nanoid 32 macro for ID generation (matching Dench's Supabase nanoid IDs)
|
||||
- SQL patterns for all CRUD operations including PIVOT for table views
|
||||
- Document management instructions
|
||||
- Workspace structure documentation
|
||||
- CRM patterns (contact, lead, deal, etc.) ported from the [Dench CRM agent prompt](file:///Users/kumareth/Documents/projects/dench/src/lib/agents/crm-agent.ts)
|
||||
|
||||
**New file:** `skills/dench/SKILL.md`
|
||||
|
||||
**Skill frontmatter:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: dench
|
||||
description: Manage Dench CRM workspace - create objects, fields, entries via DuckDB and documents as markdown files
|
||||
metadata:
|
||||
openclaw:
|
||||
inject: true
|
||||
emoji: "📊"
|
||||
---
|
||||
```
|
||||
|
||||
**Workspace directory structure managed by the skill:**
|
||||
|
||||
```
|
||||
~/.openclaw/workspace/dench/
|
||||
workspace_context.yaml # READ-ONLY context (org, members, integrations, defaults)
|
||||
workspace.duckdb # DuckDB database (CRM data)
|
||||
documents/ # Markdown documents (nested by path)
|
||||
getting-started.md
|
||||
projects/
|
||||
project-alpha.md
|
||||
exports/ # Generated CSV/Excel exports
|
||||
WORKSPACE.md # Auto-generated schema documentation
|
||||
```
|
||||
|
||||
**workspace_context.yaml** -- read-only context the agent consumes on startup (written by Dench, never by the agent):
|
||||
|
||||
```yaml
|
||||
# Dench Workspace Context (READ-ONLY)
|
||||
# This file is generated by Dench and synced via S3.
|
||||
# The agent reads this for organizational context but MUST NOT modify it.
|
||||
# Changes flow from Dench UI -> S3 -> this file (on sandbox init).
|
||||
|
||||
workspace:
|
||||
version: 1
|
||||
|
||||
# Organization identity (synced from Dench on sandbox init)
|
||||
organization:
|
||||
id: "org_abc123"
|
||||
name: "Acme Corp"
|
||||
slug: "acme-corp"
|
||||
business:
|
||||
name: "Acme Corporation"
|
||||
type: "saas" # saas, agency, ecommerce, services, etc.
|
||||
industry: "Technology"
|
||||
website: "https://acme.com"
|
||||
|
||||
# Team members -- needed for "user" type fields (e.g. "Assigned To")
|
||||
# Agent uses these IDs when creating entries with user-type fields.
|
||||
members:
|
||||
- id: "usr_abc123"
|
||||
name: "John Doe"
|
||||
email: "john@acme.com"
|
||||
role: owner
|
||||
- id: "usr_def456"
|
||||
name: "Jane Smith"
|
||||
email: "jane@acme.com"
|
||||
role: admin
|
||||
- id: "usr_ghi789"
|
||||
name: "Bob Wilson"
|
||||
email: "bob@acme.com"
|
||||
role: member
|
||||
|
||||
# Protected objects -- cannot be deleted/renamed by agent
|
||||
# Mirrors Dench's base-objects.ts immutable list
|
||||
protected_objects:
|
||||
- name: "people"
|
||||
description: "Contact records"
|
||||
icon: "users"
|
||||
- name: "companies"
|
||||
description: "Company records"
|
||||
icon: "building-2"
|
||||
|
||||
# Connected integrations -- agent reads this for sync context
|
||||
# Populated by Dench when sandbox initializes from S3
|
||||
integrations:
|
||||
connections: []
|
||||
# Example when connected:
|
||||
# - app_key: "salesforce"
|
||||
# app_name: "Salesforce"
|
||||
# connection_id: "conn_xyz"
|
||||
# synced_objects:
|
||||
# - external_resource: "Lead"
|
||||
# local_object: "lead"
|
||||
# sync_direction: bidirectional # import, export, bidirectional
|
||||
# sync_frequency: hourly # realtime, hourly, daily, manual
|
||||
# field_mappings:
|
||||
# "FirstName": "Full Name"
|
||||
# "Email": "Email Address"
|
||||
|
||||
# Enrichment configuration
|
||||
enrichment:
|
||||
enabled: false
|
||||
provider: "aviato" # aviato, apollo
|
||||
auto_enrich: false # Auto-enrich new entries on creation
|
||||
|
||||
# CRM defaults
|
||||
defaults:
|
||||
default_view: table # table, kanban
|
||||
date_format: "YYYY-MM-DD"
|
||||
naming_convention: singular_lowercase # Object names: "lead" not "Leads"
|
||||
|
||||
# S3 persistence
|
||||
sync:
|
||||
s3_bucket: "dench-workspaces"
|
||||
s3_prefix: "" # Set to org_id on init
|
||||
frequency: on_write # on_write, manual
|
||||
last_synced_at: null
|
||||
|
||||
# Credit account (for enrichment, AI operations)
|
||||
credits:
|
||||
allowance_balance: 0
|
||||
topup_balance: 0
|
||||
```
|
||||
|
||||
**Why YAML for context (not DuckDB):** This is read-only context the agent consumes once at startup -- never writes. The agent can `cat workspace_context.yaml` to understand the full org context instantly. Members list means no separate query to resolve user-type field assignments. Integrations give awareness of sync relationships without querying external APIs. This follows the Fintool pattern where user state (preferences, watchlists) lives as YAML in S3 while dense queryable data lives in the database. The data flow is one-way: Dench UI -> S3 -> workspace_context.yaml (on sandbox init). The agent never writes back to this file.
|
||||
|
||||
**DuckDB schema** (initialized by agent on first use via `duckdb dench/workspace.duckdb`):
|
||||
|
||||
```sql
|
||||
-- nanoid 32 macro: generates 32-char IDs matching Dench's Supabase nanoid format
|
||||
CREATE OR REPLACE MACRO nanoid32() AS (
|
||||
SELECT string_agg(
|
||||
substr('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-',
|
||||
(floor(random() * 64) + 1)::int, 1), '')
|
||||
FROM generate_series(1, 32)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS objects (
|
||||
id VARCHAR PRIMARY KEY DEFAULT (nanoid32()),
|
||||
name VARCHAR NOT NULL,
|
||||
description VARCHAR,
|
||||
icon VARCHAR,
|
||||
default_view VARCHAR DEFAULT 'table', -- 'table' or 'kanban'
|
||||
parent_document_id VARCHAR,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
source_app VARCHAR,
|
||||
immutable BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fields (
|
||||
id VARCHAR PRIMARY KEY DEFAULT (nanoid32()),
|
||||
object_id VARCHAR NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
|
||||
name VARCHAR NOT NULL,
|
||||
description VARCHAR,
|
||||
type VARCHAR NOT NULL, -- text, number, email, phone, boolean, date, richtext, user, relation, enum
|
||||
required BOOLEAN DEFAULT false,
|
||||
default_value VARCHAR,
|
||||
related_object_id VARCHAR REFERENCES objects(id) ON DELETE SET NULL,
|
||||
relationship_type VARCHAR, -- one_to_one, one_to_many, many_to_one, many_to_many
|
||||
enum_values JSON, -- ["New","In Progress","Done"]
|
||||
enum_colors JSON, -- ["#ef4444","#f59e0b","#22c55e"]
|
||||
enum_multiple BOOLEAN DEFAULT false,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(object_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id VARCHAR PRIMARY KEY DEFAULT (nanoid32()),
|
||||
object_id VARCHAR NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_fields (
|
||||
id VARCHAR PRIMARY KEY DEFAULT (nanoid32()),
|
||||
entry_id VARCHAR NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
field_id VARCHAR NOT NULL REFERENCES fields(id) ON DELETE CASCADE,
|
||||
value VARCHAR,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(entry_id, field_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS statuses (
|
||||
id VARCHAR PRIMARY KEY DEFAULT (nanoid32()),
|
||||
object_id VARCHAR NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
|
||||
name VARCHAR NOT NULL,
|
||||
color VARCHAR DEFAULT '#94a3b8',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_default BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(object_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id VARCHAR PRIMARY KEY DEFAULT (nanoid32()),
|
||||
title VARCHAR DEFAULT 'Untitled',
|
||||
icon VARCHAR,
|
||||
cover_image VARCHAR,
|
||||
file_path VARCHAR NOT NULL UNIQUE, -- relative path in documents/ dir
|
||||
parent_id VARCHAR REFERENCES documents(id) ON DELETE CASCADE,
|
||||
parent_object_id VARCHAR REFERENCES objects(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_published BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Full-text search index (DuckDB FTS extension)
|
||||
INSTALL fts; LOAD fts;
|
||||
```
|
||||
|
||||
**Auto-generated views** -- after every object/field mutation, the agent regenerates a PIVOT view for each object. These are stored queries (zero data duplication) that make the EAV pattern invisible:
|
||||
|
||||
```sql
|
||||
-- Auto-generated after creating the "leads" object and its fields
|
||||
CREATE OR REPLACE VIEW v_leads AS
|
||||
PIVOT (
|
||||
SELECT e.id as entry_id, e.created_at, e.updated_at,
|
||||
f.name as field_name, 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.object_id = (SELECT id FROM objects WHERE name = 'leads')
|
||||
) ON field_name USING first(value);
|
||||
|
||||
-- Now query like a normal table:
|
||||
SELECT * FROM v_leads WHERE "Status" = 'New' ORDER BY "Full Name" LIMIT 50;
|
||||
SELECT "Status", COUNT(*) FROM v_leads GROUP BY "Status";
|
||||
SELECT * FROM v_leads WHERE "Email Address" LIKE '%@gmail.com';
|
||||
```
|
||||
|
||||
Views are regenerated (not data, just the query definition) whenever fields are added/removed/renamed. Naming convention: `v_{object_name}` (e.g., `v_leads`, `v_companies`, `v_people`).
|
||||
|
||||
**Filesystem directory structure** -- auto-projected from DuckDB after schema mutations. Represents the sidebar's nested knowledge tree. NO entry data in the filesystem (DuckDB is sole source of truth for entries):
|
||||
|
||||
```
|
||||
dench/
|
||||
workspace.duckdb # SOLE source of truth for all structured data + views
|
||||
workspace_context.yaml # Read-only org context
|
||||
knowledge/ # Root of knowledge tree (= sidebar root)
|
||||
people/ # Object "people" (directory = object node in sidebar)
|
||||
.object.yaml # Lightweight metadata projection (id, icon, view, field list)
|
||||
onboarding-guide.md # Document nested UNDER the people object
|
||||
companies/ # Object "companies"
|
||||
.object.yaml
|
||||
projects/ # Document "Projects" (directory with children)
|
||||
projects.md # Document content
|
||||
tasks/ # Object nested UNDER the projects document
|
||||
.object.yaml
|
||||
roadmap.md # Sibling document
|
||||
sales/
|
||||
sales.md
|
||||
leads/ # Object nested under sales document
|
||||
.object.yaml
|
||||
follow-up-playbook.md # Document nested under leads object
|
||||
exports/ # CSV/Parquet exports (on-demand, not auto-generated)
|
||||
WORKSPACE.md # Auto-generated schema summary
|
||||
```
|
||||
|
||||
**Source of truth rules:**
|
||||
|
||||
- **Entries (rows)**: DuckDB ONLY (queried via `v_{object}` views). Never duplicated to filesystem.
|
||||
- **Fields (columns)**: DuckDB. Summary projected to `.object.yaml` (read-only).
|
||||
- **Objects (tables)**: DuckDB. Projected as directories + `.object.yaml` (read-only). Queryable via auto-generated views.
|
||||
- **Document metadata**: DuckDB. Projected as directory structure (read-only).
|
||||
- **Document content**: Filesystem (`.md` files). DuckDB stores `file_path` reference only.
|
||||
- **Nesting/ordering**: DuckDB. Projected as directory hierarchy (read-only).
|
||||
|
||||
**Key DuckDB advantages leveraged:**
|
||||
|
||||
- **PIVOT views**: Auto-generated `v_{object}` views make EAV invisible -- query like normal tables
|
||||
- **Native JSON**: `enum_values` and `enum_colors` are native JSON columns, no string parsing needed
|
||||
- **CSV import**: `COPY v_leads TO 'exports/leads.csv';` or import with `COPY ... FROM 'import.csv' (AUTO_DETECT true);`
|
||||
- **PostgreSQL dialect**: Generated SQL is directly portable to Dench's Supabase/Postgres
|
||||
|
||||
**Skill content structure** -- the full SKILL.md incorporates and adapts every section from Dench's existing CRM agent prompt ([src/lib/agents/crm-agent.ts](file:///Users/kumareth/Documents/projects/dench/src/lib/agents/crm-agent.ts)), rewritten for DuckDB/filesystem execution instead of tool calls:
|
||||
|
||||
### Section 1: Role and Workspace Startup
|
||||
|
||||
- Role: Dench CRM Management Agent operating via DuckDB and filesystem
|
||||
- On every conversation: read `dench/workspace_context.yaml` (READ-ONLY) for org context, members, integrations, protected objects
|
||||
- Initialize DuckDB if not exists: `duckdb dench/workspace.duckdb < schema.sql`
|
||||
- Database path: `dench/workspace.duckdb`, documents path: `dench/documents/`
|
||||
|
||||
### Section 2: Primary Responsibilities (adapted from `<primary_responsibilities>`)
|
||||
|
||||
- **Request analysis**: Same as original -- extract intent, identify entities/objects/fields/relationships, transform vague requests into structured SQL
|
||||
- **Object creation**: Instead of `createObjectTool`, generate `INSERT INTO objects` SQL. Naming convention: singular, lowercase (e.g., "lead", "customer"). Check existing with `SELECT` first. For kanban: auto-create Status (enum) and Assigned To (user) fields in same transaction
|
||||
- **Field management**: Instead of `createFieldTool`, generate `INSERT INTO fields` SQL. Field types: text, number, email, phone, boolean, date, richtext, user, relation, enum. Use `INSERT ... ON CONFLICT (object_id, name) DO UPDATE` for idempotency
|
||||
- **Entry creation**: Instead of `createEntryTool`, generate `INSERT INTO entries` + `INSERT INTO entry_fields` in a transaction. Resolve field names to field IDs via `SELECT id FROM fields WHERE object_id = ? AND name = ?`
|
||||
- **Entry search**: Instead of `searchEntriesTool`, generate PIVOT queries with WHERE/LIKE/ORDER BY. Use DuckDB FTS for full-text search. Operators: `=` (equals), `LIKE '%...%'` (contains), `LIKE '...%'` (startsWith), `LIKE '%...'` (endsWith), `IS NULL` (isEmpty), `IS NOT NULL` (isNotEmpty)
|
||||
|
||||
### Section 3: SQL Operation Guide (replaces `<tool_usage_guide>`)
|
||||
|
||||
Each former tool maps to SQL patterns:
|
||||
|
||||
- **createObjectTool -> INSERT object**: `INSERT INTO objects (name, description, icon, default_view) VALUES (...) ON CONFLICT (name) DO NOTHING RETURNING *;`
|
||||
- **createFieldTool -> INSERT field**: `INSERT INTO fields (object_id, name, type, required, enum_values, enum_colors, related_object_id, relationship_type, sort_order) VALUES (...) ON CONFLICT (object_id, name) DO UPDATE SET ...;`
|
||||
- **createEntryTool -> INSERT entry + entry_fields**: Transaction with `INSERT INTO entries` then `INSERT INTO entry_fields` for each field value
|
||||
- **getObjectTool -> SELECT object + fields**: `SELECT o.*, json_group_array(json_object('id', f.id, 'name', f.name, 'type', f.type)) as fields FROM objects o LEFT JOIN fields f ON f.object_id = o.id WHERE o.id = ? GROUP BY o.id;`
|
||||
- **getObjectsTool -> SELECT all objects**: `SELECT o.*, COUNT(e.id) as entry_count FROM objects o LEFT JOIN entries e ON e.object_id = o.id GROUP BY o.id ORDER BY o.sort_order;`
|
||||
- **getOrganizationMembersTool -> READ workspace_context.yaml**: Members list is in `workspace_context.yaml` under `members:`. Read with `cat dench/workspace_context.yaml` and extract the members section. User fields store member IDs like `usr_abc123`.
|
||||
- **searchEntriesTool -> query the auto-generated view**:
|
||||
```sql
|
||||
-- Simple: query the pre-built PIVOT view like a normal table
|
||||
SELECT * FROM v_leads WHERE "Status" = 'New' ORDER BY created_at DESC LIMIT 50;
|
||||
SELECT * FROM v_leads WHERE "Email Address" LIKE '%@gmail.com';
|
||||
SELECT * FROM v_leads WHERE "Full Name" ILIKE '%john%';
|
||||
SELECT "Status", COUNT(*) FROM v_leads GROUP BY "Status";
|
||||
```
|
||||
Views are auto-generated per object (`v_{object_name}`) so the agent never writes raw PIVOT queries for reads.
|
||||
- **updateObjectTool -> UPDATE object**: `UPDATE objects SET name = ?, description = ?, updated_at = now() WHERE id = ?;`
|
||||
- **updateFieldTool -> UPDATE field**: `UPDATE fields SET ... WHERE id = ?;`
|
||||
- **updateEntryTool -> UPSERT entry_fields**: `INSERT INTO entry_fields (entry_id, field_id, value) VALUES (?, ?, ?) ON CONFLICT (entry_id, field_id) DO UPDATE SET value = excluded.value, updated_at = now();`
|
||||
- **deleteObjectTool -> DELETE cascade**: `DELETE FROM objects WHERE id = ? AND immutable = false;` (cascades to fields, entries, entry_fields via FK)
|
||||
- **deleteFieldTool -> DELETE field**: `DELETE FROM fields WHERE id = ?;` (cascades to entry_fields)
|
||||
- **deleteEntryTool -> DELETE entry**: `DELETE FROM entries WHERE id = ?;` (cascades to entry_fields)
|
||||
- **createManyEntriesTool -> Batch INSERT**: Wrap multiple entry+entry_fields inserts in `BEGIN TRANSACTION; ... COMMIT;`
|
||||
- **Bulk import from CSV**: `COPY ... FROM 'import.csv' (AUTO_DETECT true);` with field mapping
|
||||
|
||||
### Section 4: Execution Workflows (adapted from `<execution_guidelines>`)
|
||||
|
||||
Same workflow selection principle: choose the minimal workflow needed based on user intent.
|
||||
|
||||
- **Create New CRM Structure**: `SELECT` to check existence -> `INSERT objects` -> `INSERT fields` (all in one `exec` call with multi-statement SQL in a transaction)
|
||||
- **Search and Display**: Generate PIVOT query with appropriate WHERE/ORDER BY/LIMIT
|
||||
- **Add New Entries**: `SELECT` object+fields -> `INSERT entries` + `INSERT entry_fields` in transaction
|
||||
- **Update Existing Data**: PIVOT query to find -> `UPDATE` entry_fields
|
||||
- **Quick Information**: `SELECT` with aggregate counts
|
||||
- **Bulk Operations**: Multi-row INSERT in transaction, report counts
|
||||
- **Data Cleanup**: PIVOT query with filters -> `DELETE` matching entries
|
||||
|
||||
Key differences from original:
|
||||
|
||||
1. **All steps happen in a single `exec` call** with multi-statement SQL, not 10+ separate tool calls
|
||||
2. **After schema mutations**: regenerate the `v_{object}` view and project the filesystem directory structure
|
||||
3. **Reads use views**: `SELECT * FROM v_leads` instead of raw PIVOT queries
|
||||
|
||||
Example of creating a full CRM structure in one shot:
|
||||
|
||||
```sql
|
||||
BEGIN TRANSACTION;
|
||||
INSERT INTO objects (name, description, icon, default_view) VALUES ('lead', 'Sales leads', 'user-plus', 'table') ON CONFLICT (name) DO NOTHING;
|
||||
INSERT INTO fields (object_id, name, type, required, sort_order) VALUES
|
||||
((SELECT id FROM objects WHERE name = 'lead'), 'Full Name', 'text', true, 0),
|
||||
((SELECT id FROM objects WHERE name = 'lead'), 'Email Address', 'email', true, 1),
|
||||
((SELECT id FROM objects WHERE name = 'lead'), 'Phone Number', 'phone', false, 2)
|
||||
ON CONFLICT (object_id, name) DO NOTHING;
|
||||
INSERT INTO fields (object_id, name, type, enum_values, enum_colors, sort_order) VALUES
|
||||
((SELECT id FROM objects WHERE name = 'lead'), 'Status', 'enum',
|
||||
'["New","Contacted","Qualified","Converted"]'::JSON,
|
||||
'["#94a3b8","#3b82f6","#f59e0b","#22c55e"]'::JSON, 3)
|
||||
ON CONFLICT (object_id, name) DO NOTHING;
|
||||
|
||||
-- Auto-generate the PIVOT view for this object
|
||||
CREATE OR REPLACE VIEW v_lead AS
|
||||
PIVOT (
|
||||
SELECT e.id as entry_id, e.created_at, e.updated_at,
|
||||
f.name as field_name, 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.object_id = (SELECT id FROM objects WHERE name = 'lead')
|
||||
) ON field_name USING first(value);
|
||||
|
||||
COMMIT;
|
||||
-- Then: project filesystem structure (mkdir knowledge/lead/, write .object.yaml)
|
||||
```
|
||||
|
||||
### Section 5: CRM Patterns (adapted from `<crm_patterns>`)
|
||||
|
||||
Identical patterns, but with SQL examples instead of tool call examples:
|
||||
|
||||
- **Contact/Customer**: Full Name (text, required), Email Address (email, required), Phone Number (phone), Company (relation), Notes (richtext)
|
||||
- **Lead/Prospect**: Full Name, Email, Phone, Status (enum: New/Contacted/Qualified/Converted), Source (enum), Score (number), Assigned To (user), Notes (richtext)
|
||||
- **Company/Organization**: Company Name (text, required), Industry (enum), Website (text), Type (enum), Notes (richtext)
|
||||
- **Deal/Opportunity**: Deal Name, Amount (number), Stage (enum: Discovery/Proposal/Negotiation/Closed Won/Closed Lost), Close Date (date), Probability (number), Primary Contact (relation), Assigned To (user), Notes (richtext)
|
||||
- **Case/Project**: Case Number, Title, Client (relation), Status (enum), Priority (enum), Due Date (date), Assigned To (user), Notes (richtext)
|
||||
- **Property/Asset**: Address, Property Type (enum), Price (number), Status (enum), Square Footage (number), Bedrooms (number), Notes (richtext)
|
||||
- **Task/Activity** (kanban): Title, Description, Assigned To (user), Due Date, Status (enum: In Queue/In Progress/Done), Priority (enum), Notes (richtext). Use `default_view = 'kanban'` and auto-create Status + Assigned To fields.
|
||||
|
||||
### Section 6: Field Type Reference (adapted from `<field_type_selection>`)
|
||||
|
||||
- **text**: General text data, names, descriptions, addresses. Stored as VARCHAR.
|
||||
- **email**: Email addresses. Stored as VARCHAR. Agent validates format.
|
||||
- **phone**: Phone numbers. Stored as VARCHAR. Agent normalizes format.
|
||||
- **number**: Numeric values (prices, quantities, scores). Stored as VARCHAR in entry_fields (EAV), cast with `::NUMERIC` in queries.
|
||||
- **boolean**: Yes/no flags. Stored as "true"/"false" strings.
|
||||
- **date**: Dates. Stored as ISO 8601 strings. Cast with `::DATE` in queries.
|
||||
- **richtext**: Rich text for Notes fields. Content stored as entry_field value (plain text / markdown). Displayed in Notion-style editor in Dench UI.
|
||||
- **user**: Person/assignee fields. Stores member ID (e.g., "usr_abc123") from `workspace_context.yaml` members list. ALWAYS resolve member name -> ID before inserting.
|
||||
- **enum**: Dropdown/select fields. Field definition stores `enum_values` as JSON array. Entry stores the selected value string. `enum_colors` parallel array for styling. `enum_multiple = true` for multi-select (value stored as JSON array string).
|
||||
- **relation**: Links to entries in another object. Field stores `related_object_id` and `relationship_type`. Entry stores the related entry ID(s). `many_to_one` for single select, `many_to_many` for multi-select (stored as JSON array).
|
||||
|
||||
### Section 7: Naming Conventions and Data Handling (adapted from `<field_naming_conventions>` and `<data_handling_best_practices>`)
|
||||
|
||||
- Object names: singular, lowercase, one word ("lead" not "Leads")
|
||||
- Field names: human-readable, proper capitalization ("Email Address" not "email")
|
||||
- Be descriptive: "Phone Number" not "Phone"
|
||||
- Be consistent within an object
|
||||
- Validate email formats, normalize phone numbers, use ISO 8601 dates
|
||||
- Trim whitespace from all values
|
||||
- Check for duplicates before creating entries (SELECT before INSERT)
|
||||
|
||||
### Section 8: Error Handling (adapted from `<error_handling>`)
|
||||
|
||||
- `UNIQUE constraint` on INSERT -> item already exists, treat as SUCCESS (use `ON CONFLICT DO NOTHING` or `DO UPDATE`)
|
||||
- Protected object deletion -> check `immutable` column and `protected_objects` in workspace_context.yaml
|
||||
- Field type mismatch -> warn user before changing type on field with existing data
|
||||
- Missing required fields -> validate before INSERT, report which fields are missing
|
||||
|
||||
### Section 9: Document Management (new -- not in original CRM agent)
|
||||
|
||||
- Create document: `write` tool to create `dench/documents/<path>.md` + `INSERT INTO documents` with metadata
|
||||
- Document content is the .md file; DuckDB `documents` table tracks metadata (title, icon, nesting, order)
|
||||
- Cross-nesting: documents under objects (`parent_object_id`), objects under documents (`parent_document_id`)
|
||||
- Document tree mirrors filesystem: `dench/documents/projects/alpha.md` -> `file_path = 'projects/alpha.md'`
|
||||
|
||||
### Section 10: Protected Objects and Read-Only Context (new)
|
||||
|
||||
- Read `protected_objects` from `workspace_context.yaml` on startup
|
||||
- NEVER delete, rename, or modify immutable objects (People, Companies)
|
||||
- NEVER modify `workspace_context.yaml` -- it is read-only context from Dench
|
||||
- Members list is authoritative for user-type field resolution
|
||||
|
||||
### Section 11: Post-Mutation Checklist (MANDATORY -- revised after agent testing)
|
||||
|
||||
**Problem identified:** In testing, the agent correctly executed SQL (object + fields + entries) but skipped the filesystem projection (.object.yaml) and sometimes the PIVOT view. Root cause: the original skill mentioned these as afterthoughts ("Then project the filesystem...") with no concrete template or examples. The agent follows examples literally -- if examples only show SQL, it only does SQL.
|
||||
|
||||
**Fix:** Every workflow example now uses an explicit 3-step structure. The post-mutation section is now a checklist, not a description.
|
||||
|
||||
After creating/modifying an OBJECT or FIELDS:
|
||||
|
||||
- `CREATE OR REPLACE VIEW v_{object_name}` -- regenerate PIVOT view
|
||||
- `mkdir -p dench/knowledge/{object_name}/` -- create directory
|
||||
- Write `.object.yaml` with id, name, description, icon, default_view, entry_count, and full field list
|
||||
- Update WORKSPACE.md
|
||||
|
||||
After adding ENTRIES:
|
||||
|
||||
- Update `entry_count` in `.object.yaml`
|
||||
- Verify: `SELECT * FROM v_{object} LIMIT 5`
|
||||
|
||||
After deleting an OBJECT:
|
||||
|
||||
- `DROP VIEW IF EXISTS v_{object_name}`
|
||||
- `rm -rf dench/knowledge/{object_name}/`
|
||||
|
||||
The skill now includes:
|
||||
|
||||
- A concrete `.object.yaml` template with example content (previously missing entirely)
|
||||
- Full bash commands for generating `.object.yaml` from DuckDB queries
|
||||
- "Step 1 / Step 2 / Step 3" structure in every workflow example (SQL, Filesystem, Verify)
|
||||
- Critical Reminders section leads with "NEVER SKIP FILESYSTEM PROJECTION" and "THREE STEPS, EVERY TIME"
|
||||
|
||||
### Section 12: Critical Reminders (adapted from `<critical_reminders>`)
|
||||
|
||||
- Handle the ENTIRE CRM operation from analysis to SQL execution **to filesystem projection** to summary
|
||||
- **NEVER SKIP FILESYSTEM PROJECTION**: After any object mutation, create/update `.object.yaml` AND the `v_{object}` view. If missing, the object is invisible in the sidebar.
|
||||
- **THREE STEPS, EVERY TIME**: (1) SQL transaction, (2) filesystem projection, (3) verify
|
||||
- Always check existing data before creating (SELECT before INSERT, or ON CONFLICT)
|
||||
- Search proactively to provide better UX (PIVOT with filters)
|
||||
- Never assume field names -- always verify with `SELECT * FROM fields WHERE object_id = ?`
|
||||
- Extract ALL data from user messages
|
||||
- NOTES FIELDS: type "richtext", displayed in Notion editor
|
||||
- USER FIELDS: Resolve member name to ID from workspace_context.yaml BEFORE inserting
|
||||
- ENUM FIELDS: type "enum" with `enum_values` JSON array
|
||||
- RELATION FIELDS: type "relation" with `related_object_id`
|
||||
- KANBAN BOARDS: `default_view = 'kanban'`, auto-create Status and Assigned To fields
|
||||
- PROTECTED OBJECTS: Never delete objects listed in `workspace_context.yaml` `protected_objects`
|
||||
- ONE EXEC CALL: Batch related SQL in a single transaction whenever possible -- this is the entire point of the filesystem-first approach
|
||||
- ENTRY COUNT: After adding entries, update `entry_count` in `.object.yaml`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: S3 Sync Layer
|
||||
|
||||
**Two sync directions:**
|
||||
|
||||
1. **Sandbox -> S3:** After every database write or document save, sync changed files to S3
|
||||
2. **S3 -> Sandbox:** On sandbox startup, download the latest dench/ folder from S3
|
||||
|
||||
**Option A (simpler): Script-based sync**
|
||||
|
||||
- Add a `dench/sync.sh` script that the agent calls after mutations
|
||||
- Uses AWS CLI: `aws s3 sync dench/ s3://dench-workspaces/{orgId}/`
|
||||
- Credentials injected via ABAC (short-lived, scoped to org prefix)
|
||||
- Skill instructs the agent to run sync after writes
|
||||
|
||||
**Option B (more robust): inotifywait + background sync**
|
||||
|
||||
- Background process watches dench/ for changes
|
||||
- Debounced sync to S3 (e.g., 5s after last change)
|
||||
- More reliable but more infrastructure
|
||||
|
||||
**Recommendation:** Start with Option A. The agent is already making exec calls; one more `aws s3 sync` is trivial.
|
||||
|
||||
**New file:** `skills/dench/sync.sh` (bundled with the skill, referenced by SKILL.md)
|
||||
|
||||
**Sandbox startup hook:**
|
||||
|
||||
- Add to OpenClaw's sandbox initialization: download dench workspace from S3 if it exists
|
||||
- Could be a `BOOTSTRAP.md` instruction or a sandbox pre-warm step
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Dench Sidebar Integration
|
||||
|
||||
The Dench sidebar currently reads from Prisma (`getAll` objects + `getAll` documents). It needs a new data source for workspace-managed data.
|
||||
|
||||
**Approach:** Add a new tRPC endpoint that reads from S3 (or the synced PostgreSQL `fs_files` table) and returns the same tree structure the sidebar expects.
|
||||
|
||||
**Files to modify in Dench:**
|
||||
|
||||
- [src/lib/trpc/routers/objects.ts](file:///Users/kumareth/Documents/projects/dench/src/lib/trpc/routers/objects.ts) -- Add `getWorkspaceObjects` endpoint that queries the synced SQLite data (either by reading dench.db from S3, or from a PostgreSQL materialized view)
|
||||
- [src/lib/trpc/routers/documents.ts](file:///Users/kumareth/Documents/projects/dench/src/lib/trpc/routers/documents.ts) -- Add `getWorkspaceDocuments` endpoint
|
||||
- [src/components/app-sidebar.tsx](file:///Users/kumareth/Documents/projects/dench/src/components/app-sidebar.tsx) -- Merge workspace data into the `buildKnowledgeTree` function
|
||||
|
||||
**S3 -> PostgreSQL sync (Lambda):**
|
||||
|
||||
- When workspace.duckdb is uploaded to S3, a Lambda function:
|
||||
1. Downloads the DuckDB file
|
||||
2. Reads objects, fields, entries, documents tables
|
||||
3. Upserts into PostgreSQL tables (or a dedicated `workspace_objects`, `workspace_documents` table)
|
||||
- This mirrors the Fintool pattern: S3 source of truth, Lambda sync, PG for fast queries
|
||||
|
||||
**Alternative (simpler for v1):** Dench backend downloads workspace.duckdb from S3 on demand and queries it directly using `duckdb` Node.js bindings (`@duckdb/node-api`). No Lambda needed initially.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Real-Time Updates (Future)
|
||||
|
||||
- WebSocket notifications when S3 objects change (via S3 Event Notifications -> SNS -> WebSocket)
|
||||
- Dench sidebar auto-refreshes when workspace data changes
|
||||
- Bidirectional: Dench UI edits write back to S3, agent picks up changes on next sandbox start
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **Three-layer storage** (following Fintool pattern):
|
||||
- **YAML** (`workspace_context.yaml`): Read-only workspace identity, team members, integrations, defaults. Written by Dench, consumed by agent. Never modified by the agent -- data flows one-way from Dench UI -> S3 -> this file.
|
||||
- **DuckDB** (`workspace.duckdb`): Dense, relational CRM data (objects, fields, entries, statuses). Queryable via SQL with PIVOT, JOIN, FTS.
|
||||
- **Markdown** (`documents/*.md`): Rich document content. Agent uses `write`/`edit` tools directly. DuckDB tracks metadata (title, icon, nesting) while the file system holds the content.
|
||||
- **DuckDB over SQLite**: Native PIVOT/UNPIVOT is essential for the EAV data model (rendering custom-field entries as tables). PostgreSQL-compatible SQL dialect means generated SQL is portable to Dench's Supabase/Postgres. Native JSON type eliminates string parsing for enum_values/colors. Built-in CSV/Parquet import with auto-detection enables bulk CRM operations. FTS extension provides full-text search. ~50-100ms startup overhead is acceptable.
|
||||
- **nanoid 32 IDs**: Matches Dench's Supabase PostgreSQL nanoid format. Generated via a DuckDB macro using `generate_series` + `random()` over the standard nanoid alphabet (`0-9A-Za-z_-`). 32 chars provides 192 bits of entropy.
|
||||
- **Members in YAML not DuckDB**: The agent needs member IDs for "user" type fields (like "Assigned To"). Putting members in workspace_context.yaml means the agent reads them once on startup without a separate SQL query. The list changes infrequently (team changes, not per-request). Agent reads only, never writes.
|
||||
- **Integrations in YAML not DuckDB**: The agent needs to know what apps are connected and how fields map, but doesn't need to query this relationally. YAML gives the agent instant context about sync relationships. Agent reads only, never writes.
|
||||
- **Skill inject vs bootstrap file**: Using skill metadata `inject: true` is cleaner than adding a new bootstrap file type. It keeps the Dench instructions modular and version-controlled in the skills directory.
|
||||
- **One transaction per operation**: The skill instructs the agent to wrap multi-step operations in `BEGIN ... COMMIT` for atomicity. DuckDB supports full ACID transactions.
|
||||
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
|
||||
324
.cursor/plans/full_web_ui_redesign_9ad2e285.plan.md
Normal file
324
.cursor/plans/full_web_ui_redesign_9ad2e285.plan.md
Normal file
@ -0,0 +1,324 @@
|
||||
---
|
||||
name: Full Web UI Redesign
|
||||
overview: "Complete redesign of the OpenClaw web app (apps/web/) to match the Dench design system: switch from dark theme to light, adopt Instrument Serif + Inter fonts, port the Dench color palette and layout patterns, and rewrite every component and page from the ground up."
|
||||
todos:
|
||||
- id: foundation
|
||||
content: "Phase 1: Rewrite globals.css (light theme, HSL tokens, font imports) and layout.tsx (next/font, remove dark mode)"
|
||||
status: pending
|
||||
- id: landing
|
||||
content: "Phase 2: Rewrite app/page.tsx as Dench-style landing page (navbar, hero, demo sections, footer)"
|
||||
status: pending
|
||||
- id: layout-shell
|
||||
content: "Phase 3: Create app-navbar.tsx, rewrite workspace/page.tsx layout with top navbar + sidebar grid"
|
||||
status: pending
|
||||
- id: sidebar
|
||||
content: "Phase 4: Redesign workspace-sidebar.tsx and file-manager-tree.tsx to match Dench sidebar"
|
||||
status: pending
|
||||
- id: data-table
|
||||
content: "Phase 5: Redesign object-table.tsx with Dench-style toolbar, sticky headers, pagination, enum badges"
|
||||
status: pending
|
||||
- id: kanban
|
||||
content: "Phase 6: Redesign object-kanban.tsx with light cards, columns, board header"
|
||||
status: pending
|
||||
- id: entry-detail
|
||||
content: "Phase 7: Redesign entry-detail-modal.tsx as right-panel slide-out with properties list"
|
||||
status: pending
|
||||
- id: dashboard-chat
|
||||
content: "Phase 8a: Build dashboard view with greeting, centered chat input, suggestion chips, and animate-down-to-bottom Framer Motion layoutId transition"
|
||||
status: pending
|
||||
- id: chat
|
||||
content: "Phase 8b: Restyle chat-panel.tsx, chat-message.tsx, chain-of-thought.tsx for light theme + bottom composer"
|
||||
status: pending
|
||||
- id: remaining
|
||||
content: "Phase 9: Restyle all remaining components (breadcrumbs, document-view, file-viewer, database-viewer, empty-state, markdown, context-menu, slash-command, charts, etc.)"
|
||||
status: pending
|
||||
- id: deps
|
||||
content: "Phase 10: Add framer-motion dependency, verify fonts work, test build"
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Full Web UI Redesign — Dench Design System
|
||||
|
||||
## Current State
|
||||
|
||||
The OpenClaw web app is a **dark-themed** Next.js 15 app with:
|
||||
|
||||
- Dark background (`#0a0a0a`), dark surfaces (`#141414`), orange accent (`#e85d3a`)
|
||||
- Inter font only, no serif headings
|
||||
- Minimal homepage (centered text + CTA)
|
||||
- Workspace layout: left sidebar (260px) + content + optional chat panel
|
||||
- Custom table/kanban/viewer components, all dark-styled
|
||||
- Tailwind v4 (CSS-based config), no shadcn/ui
|
||||
|
||||
## Target State (Dench Design)
|
||||
|
||||
Per the screenshots and Dench source:
|
||||
|
||||
- **Light theme** — `bg-neutral-50` layout, white cards, `bg-neutral-100` sidebar/navbar
|
||||
- **Instrument Serif** for headings/titles, **Inter** for body text, **Lora** for branding
|
||||
- **Top navbar** (grid 3-col, with Dashboard/Workflows/Integrations tabs, org logo, user menu)
|
||||
- **Left sidebar** (260px, `bg-neutral-100`, collapsible knowledge tree with item counts)
|
||||
- **Data tables** with: sticky header, column borders, search bar, filter/column controls, enum badges, relation chips, pagination
|
||||
- **Kanban board** with rounded cards, priority badges, assignee avatars
|
||||
- **Entry detail** right-panel slide-out with property list
|
||||
- **Landing page** with hero section, demo sections, clean navigation bar
|
||||
- **Dashboard chat UX** — centered greeting ("Good evening, Kumar?") in Instrument Serif + centered chat input with suggestion chips; on first message, the input animates down to a bottom-docked composer via Framer Motion shared `layoutId` spring transition
|
||||
- HSL-based CSS variables (shadcn pattern), `--radius: 0.5rem`, neutral base color
|
||||
|
||||
## Architecture Decision: Tailwind v4
|
||||
|
||||
The OpenClaw app uses **Tailwind v4** (CSS-based config via `@import "tailwindcss"`), while Dench uses Tailwind v3 (JS config). We will keep Tailwind v4 but port all design tokens into `globals.css` using `@theme` blocks and CSS custom properties. No downgrade needed.
|
||||
|
||||
## Architecture Decision: Light + Dark Theme
|
||||
|
||||
Dench is light-only. We will use Dench's light palette as the `:root` default AND create a custom dark palette under `.dark` (class-based toggle via `<html class="dark">`). All components will use CSS variable references (e.g. `bg-background`, `text-foreground`, `border-border`) so they automatically adapt. No hardcoded hex/rgb in components.
|
||||
|
||||
**Light palette** (from Dench):
|
||||
|
||||
- `--background: 0 0% 96%` (neutral-50 feel)
|
||||
- `--foreground: 0 0% 3.9%`
|
||||
- `--card: 0 0% 100%` / `--card-foreground: 0 0% 3.9%`
|
||||
- `--muted: 0 0% 96.1%` / `--muted-foreground: 0 0% 45.1%`
|
||||
- `--border: 0 0% 89.8%`
|
||||
- `--primary: 0 0% 9%` / `--primary-foreground: 0 0% 98%`
|
||||
- `--accent: 0 0% 96.1%` / `--accent-foreground: 0 0% 9%`
|
||||
- `--destructive: 0 84.2% 60.2%`
|
||||
|
||||
**Dark palette** (custom, designed to complement Dench's light theme):
|
||||
|
||||
- `--background: 0 0% 7%` (#121212 — rich near-black, not pure black)
|
||||
- `--foreground: 0 0% 93%` (#ededed)
|
||||
- `--card: 0 0% 10%` (#1a1a1a) / `--card-foreground: 0 0% 93%`
|
||||
- `--muted: 0 0% 14%` (#242424) / `--muted-foreground: 0 0% 55%` (#8c8c8c)
|
||||
- `--border: 0 0% 18%` (#2e2e2e)
|
||||
- `--primary: 0 0% 93%` / `--primary-foreground: 0 0% 9%`
|
||||
- `--accent: 0 0% 16%` (#292929) / `--accent-foreground: 0 0% 93%`
|
||||
- `--destructive: 0 62% 55%`
|
||||
- Sidebar: `--sidebar-bg: 0 0% 9%` (#171717)
|
||||
- Navbar: similar to sidebar, subtle `border-b` at `--border`
|
||||
|
||||
Sidebar/navbar in dark mode use a slightly elevated surface (`#171717`) rather than pure background, for depth.
|
||||
|
||||
**Theme toggle:** add a sun/moon toggle button in the navbar (right side, near user avatar). Use `next-themes` or a simple `useEffect` + `localStorage` approach to persist preference and apply `.dark` class on `<html>`.
|
||||
|
||||
---
|
||||
|
||||
## Files to Change
|
||||
|
||||
### Phase 1 — Foundation (Theme, Fonts, Layout Shell)
|
||||
|
||||
**[app/globals.css](apps/web/app/globals.css)** — Complete rewrite:
|
||||
|
||||
- `:root` block: Dench's light-theme HSL palette (background, foreground, card, primary, secondary, muted, accent, destructive, border, ring, sidebar, chart-1 through chart-5)
|
||||
- `.dark` block: custom dark palette (see "Architecture Decision: Light + Dark Theme" above) — all same variable names, dark values
|
||||
- Add `@theme` block for Tailwind v4 mapping CSS vars to utility classes (`bg-background`, `text-foreground`, `border-border`, `bg-card`, `text-muted-foreground`, etc.)
|
||||
- Import Instrument Serif from Google Fonts
|
||||
- Add `.font-instrument` utility class
|
||||
- Port scrollbar, prose, editor, and slash-command styles using CSS variables (theme-aware, not hardcoded)
|
||||
- Port workflow state colors (`--workflow-active`, `--workflow-processing`, `--workflow-idle`)
|
||||
|
||||
**[app/layout.tsx](apps/web/app/layout.tsx)** — Rewrite:
|
||||
|
||||
- Import Inter and Lora via `next/font/google`
|
||||
- Set CSS variables `--font-corporate` and `--font-lora`
|
||||
- Default to light: no `className="dark"` on `<html>` (let theme provider handle it)
|
||||
- Apply `font-corporate` to `<body>`
|
||||
- Add `suppressHydrationWarning` on `<html>` for theme flash prevention
|
||||
- Add inline script or `next-themes` `ThemeProvider` for class-based dark mode toggle with `localStorage` persistence
|
||||
- Update metadata title/description to "Dench" branding
|
||||
|
||||
**New: `app/hooks/use-theme.ts**` — Simple theme hook:
|
||||
|
||||
- Read/write `localStorage` key `"theme"` (`"light"` | `"dark"` | `"system"`)
|
||||
- Apply/remove `.dark` class on `document.documentElement`
|
||||
- Expose `theme`, `setTheme`, `resolvedTheme` for components
|
||||
|
||||
### Phase 2 — Landing Page
|
||||
|
||||
**[app/page.tsx](apps/web/app/page.tsx)** — Full rewrite to match Dench landing:
|
||||
|
||||
- Sticky navigation bar (logo "Dench" in `font-lora`, Login button in rounded-full blue pill)
|
||||
- Hero section: "AI CRM" headline in `font-instrument font-bold`, subtext, "Get Started Free" CTA
|
||||
- Full-width CRM demo area (window chrome with traffic-light dots, scaled mock table)
|
||||
- Additional demo sections (workflow, kanban) — simplified versions
|
||||
- Footer with copyright, links
|
||||
|
||||
### Phase 3 — Workspace Layout Shell
|
||||
|
||||
**[app/workspace/page.tsx](apps/web/app/workspace/page.tsx)** — Rewrite layout structure:
|
||||
|
||||
- Add top `AppNavbar` component: `bg-neutral-100 border-b border-border shadow-[0_0_40px_rgba(0,0,0,0.05)]`
|
||||
- Left: org logo + "Powered by Dench" + org name in `font-instrument`
|
||||
- Center: tab navigation (Dashboard, Workflows, Integrations) with active state
|
||||
- Right: credit display, notification bell, sun/moon theme toggle, user avatar dropdown
|
||||
- Main area: `grid lg:grid-cols-[260px_1fr]` under navbar
|
||||
- Full height: `h-[100dvh] flex flex-col bg-neutral-50`
|
||||
- Content area: `overflow-y-auto overflow-x-hidden`
|
||||
- Replace all inline `style={{}}` dark colors with Tailwind classes
|
||||
|
||||
**New component: `app/components/workspace/app-navbar.tsx**` — Top navbar (extracted for reuse)
|
||||
|
||||
### Phase 4 — Sidebar Redesign
|
||||
|
||||
**[app/components/workspace/workspace-sidebar.tsx](apps/web/app/components/workspace/workspace-sidebar.tsx)** — Full rewrite:
|
||||
|
||||
- Background: `bg-sidebar` with `border-r border-border` (light: neutral-100, dark: #171717 via CSS var)
|
||||
- Shadow: theme-aware subtle shadow
|
||||
- Header: "KNOWLEDGE" section label in uppercase `text-[11px] font-medium tracking-wider text-muted-foreground`
|
||||
- Knowledge items: `text-[13px]`, hover `bg-accent`, `rounded-xl`
|
||||
- Item badges showing entry counts in `bg-muted border border-border` pills
|
||||
- Icons per item type (objects get custom icons, documents get doc icon)
|
||||
- Collapsible sections: KNOWLEDGE, CHATS, TELEPHONY
|
||||
- Bottom: "API Keys" link
|
||||
- Remove all inline `style={{}}` dark colors
|
||||
|
||||
**[app/components/workspace/file-manager-tree.tsx](apps/web/app/components/workspace/file-manager-tree.tsx)** — Restyle tree items:
|
||||
|
||||
- Light-theme hover states, active states matching `bg-neutral-200`
|
||||
- `text-[13px]` sizing, proper icon colors
|
||||
- Drag-and-drop visual indicators in light theme
|
||||
|
||||
### Phase 5 — Data Table Redesign
|
||||
|
||||
**[app/components/workspace/object-table.tsx](apps/web/app/components/workspace/object-table.tsx)** — Complete rewrite to match Dench data-table:
|
||||
|
||||
- Toolbar: object name in `font-instrument`, search input (`rounded-full shadow-[0_0_21px_0_rgba(0,0,0,0.07)]`), "Ask AI" button, Table/Board view toggle, refresh/import/filter/columns/+ Add buttons
|
||||
- Table header: `sticky top-0 z-30 bg-card border-b-2 border-border/80`, sortable columns with sort arrows
|
||||
- Table cells: `px-4 border-r border-border/30`, proper text truncation
|
||||
- Enum badges: colored pill style matching Dench (translucent background + border)
|
||||
- Relation chips: link icon + blue text
|
||||
- Row hover: `hover:bg-muted/50`
|
||||
- Pagination bar: "Showing 1 to N of N results", rows-per-page selector, page navigation
|
||||
- "..." action menu per row (right column)
|
||||
|
||||
### Phase 6 — Kanban Board Redesign
|
||||
|
||||
**[app/components/workspace/object-kanban.tsx](apps/web/app/components/workspace/object-kanban.tsx)** — Rewrite:
|
||||
|
||||
- Board header: view toggle (Board/Table), "Ask AI" button, search, "Group by" selector
|
||||
- Columns: `bg-muted/50 rounded-2xl border border-border/60`, column title + count badge
|
||||
- Cards: `bg-card rounded-xl border border-border/80 shadow-sm`
|
||||
- Card content: title, field badges (objective, risk profile), date, assignee avatar
|
||||
- "+ Add Item" at column bottom
|
||||
- "Drop cards here" empty column placeholder
|
||||
|
||||
### Phase 7 — Entry Detail Panel
|
||||
|
||||
**[app/components/workspace/entry-detail-modal.tsx](apps/web/app/components/workspace/entry-detail-modal.tsx)** — Redesign as right-panel slide-out:
|
||||
|
||||
- Takes ~40% of content width, pushes table left
|
||||
- Header: icon + title in large font, "Created Jan 12, 2026 at 12:47 PM" subtitle
|
||||
- "PROPERTIES" section label
|
||||
- Property rows: label (uppercase text-xs text-muted-foreground) + value
|
||||
- Relation fields show colored link chips
|
||||
- Enum fields show colored badges (matching table)
|
||||
- "Add a property" at bottom
|
||||
- Close button (>> icon) top-right
|
||||
|
||||
### Phase 8a — Dashboard Chat UX (Greeting + Animate-to-Bottom Input)
|
||||
|
||||
This is the hero interaction on the workspace "Dashboard" tab — a centered greeting with a chat input that transitions into the bottom-docked composer after the first message.
|
||||
|
||||
**How Dench implements it:**
|
||||
|
||||
- `DashboardHeader`: time-based greeting ("Good morning/afternoon/evening, Name?") with staggered word-by-word Framer Motion entrance (`y:20 → 0`, `blur(8px) → blur(0)`)
|
||||
- `DashboardChatbox`: centered TipTap input with placeholder "Build a workflow to automate your tasks", attach/voice/submit buttons, suggestion chips below (shuffled from a pool of ~27 templates, showing 7 in two rows)
|
||||
- **Layout animation:** both the centered input and the bottom composer share a Framer Motion `layoutId="chat-thread-composer"`. When `showStartComposer` flips to false after the first message, Framer Motion automatically animates the input from center to bottom with `transition={{ type: "spring", stiffness: 260, damping: 30 }}`
|
||||
|
||||
**New components to create:**
|
||||
|
||||
`app/components/workspace/dashboard-view.tsx` — Dashboard home view:
|
||||
|
||||
- Greeting in `font-instrument text-4xl` with time-based message + user name
|
||||
- Word-by-word staggered Framer Motion entrance animation
|
||||
- Centered chat input area below greeting
|
||||
|
||||
`app/components/workspace/dashboard-chatbox.tsx` — Centered input + chips:
|
||||
|
||||
- Rounded white card with subtle shadow, textarea/input with placeholder
|
||||
- Attach (paperclip), voice (mic), submit (arrow) icon buttons
|
||||
- Suggestion chip rows: 3 on first row, 4 on second row, each with icon + label + `rounded-xl` border
|
||||
- Accepts `layoutId` prop for shared layout animation
|
||||
- `mode` prop: `"dashboard"` (centered, with greeting) vs `"thread"` (same input but used within chat thread)
|
||||
- Entry animation: `opacity: 0, y: 20` → `opacity: 1, y: 0`, duration 0.8s
|
||||
|
||||
**Modify [app/workspace/page.tsx](apps/web/app/workspace/page.tsx):**
|
||||
|
||||
- When no content selected (Dashboard tab active), render `DashboardView`
|
||||
- On chat submit: transition to chat thread view
|
||||
- Use `LayoutGroup` from Framer Motion to wrap the dashboard + chat area
|
||||
- Track `showStartComposer` state: when true, show centered `DashboardChatbox`; when false, show messages + bottom `ChatComposer` — both sharing the same `layoutId`
|
||||
|
||||
**Prompt templates** (simplified set for OpenClaw):
|
||||
|
||||
- Follow-up Emails, Calendly Prep, Zoom Recap, Facebook Leads, Calendar Sync, Salesforce Sync, Intercom Chat (matching the Dench screenshot chips)
|
||||
|
||||
### Phase 8b — Chat & Message Restyling
|
||||
|
||||
**[app/components/chat-panel.tsx](apps/web/app/components/chat-panel.tsx)** — Restyle:
|
||||
|
||||
- Theme-aware background (`bg-card`), card-colored input area
|
||||
- Input: rounded border, subtle shadow, consistent with dashboard chatbox style
|
||||
- Bottom-docked composer with `layoutId` for shared animation
|
||||
- Session tabs in light theme
|
||||
- Tool call indicators in light theme
|
||||
- Send button styling (rounded, neutral)
|
||||
|
||||
**[app/components/chat-message.tsx](apps/web/app/components/chat-message.tsx)** — Restyle:
|
||||
|
||||
- Theme-aware message bubbles (user: `bg-muted`, assistant: `bg-card`)
|
||||
- Code blocks with `bg-muted`
|
||||
- Markdown rendering in light theme
|
||||
- Chain-of-thought styling update
|
||||
|
||||
**[app/components/chain-of-thought.tsx](apps/web/app/components/chain-of-thought.tsx)** — Light theme
|
||||
|
||||
### Phase 9 — Remaining Components
|
||||
|
||||
All components below: replace every hardcoded color (`style={{}}`, hex, rgb) with semantic Tailwind utilities (`bg-background`, `text-foreground`, `border-border`, `bg-card`, `text-muted-foreground`, `bg-muted`, etc.) so they work in both light and dark:
|
||||
|
||||
- **[breadcrumbs.tsx](apps/web/app/components/workspace/breadcrumbs.tsx)** — `text-muted-foreground`, `hover:text-foreground`
|
||||
- **[document-view.tsx](apps/web/app/components/workspace/document-view.tsx)** — `bg-card` background, `border-border`
|
||||
- **[file-viewer.tsx](apps/web/app/components/workspace/file-viewer.tsx)** — `bg-muted` code blocks, `text-foreground`
|
||||
- **[database-viewer.tsx](apps/web/app/components/workspace/database-viewer.tsx)** — `bg-card` tables, `bg-muted` query editor
|
||||
- **[empty-state.tsx](apps/web/app/components/workspace/empty-state.tsx)** — `text-muted-foreground` illustration
|
||||
- **[markdown-content.tsx](apps/web/app/components/workspace/markdown-content.tsx)** — Prose styles via CSS vars
|
||||
- **[markdown-editor.tsx](apps/web/app/components/workspace/markdown-editor.tsx)** — `bg-card` editor chrome
|
||||
- **[context-menu.tsx](apps/web/app/components/workspace/context-menu.tsx)** — `bg-card` dropdown, `border-border`
|
||||
- **[slash-command.tsx](apps/web/app/components/workspace/slash-command.tsx)** — `bg-card` command palette
|
||||
- **[inline-rename.tsx](apps/web/app/components/workspace/inline-rename.tsx)** — `bg-card` input, `border-border`
|
||||
- **[knowledge-tree.tsx](apps/web/app/components/workspace/knowledge-tree.tsx)** — Theme-aware tree styles
|
||||
- **[charts/](apps/web/app/components/charts/)** — All chart components: CSS var chart colors, `bg-card` panels
|
||||
- **[sidebar.tsx](apps/web/app/components/sidebar.tsx)** — Theme-aware (if still used)
|
||||
|
||||
### Phase 10 — Package Dependencies
|
||||
|
||||
**[package.json](apps/web/package.json)** — Add if needed:
|
||||
|
||||
- `framer-motion` (for landing page + dashboard chat animations)
|
||||
- `next-themes` (for dark/light toggle with `localStorage` + class-based switching, SSR-safe)
|
||||
- Verify `next/font/google` is available (bundled with Next.js)
|
||||
|
||||
---
|
||||
|
||||
## Key Design Tokens
|
||||
|
||||
- **Radius:** `0.5rem` base
|
||||
- **Primary font:** Inter via `next/font/google`
|
||||
- **Heading font:** Instrument Serif via Google Fonts import
|
||||
- **Brand font:** Lora via `next/font/google`
|
||||
- **Sidebar width:** 260px
|
||||
- **Shadows (light):** `shadow-[0_0_40px_rgba(0,0,0,0.05)]` (sidebar/navbar), `shadow-[0_0_21px_0_rgba(0,0,0,0.07)]` (search)
|
||||
- **Shadows (dark):** `shadow-[0_0_40px_rgba(0,0,0,0.2)]` (sidebar/navbar), `shadow-[0_0_21px_0_rgba(0,0,0,0.15)]` (search)
|
||||
|
||||
## Component Styling Rules (Theme-Safe)
|
||||
|
||||
All components MUST use semantic CSS variable-backed utilities — never hardcoded colors:
|
||||
|
||||
- `bg-background` / `bg-card` / `bg-muted` / `bg-accent` — not `bg-white`, `bg-neutral-50`, `bg-[#1a1a1a]`
|
||||
- `text-foreground` / `text-muted-foreground` / `text-card-foreground` — not `text-black`, `text-gray-500`
|
||||
- `border-border` — not `border-neutral-200`, `border-[#2e2e2e]`
|
||||
- `bg-sidebar` for sidebar/navbar backgrounds
|
||||
- For shadows that differ between themes: use a CSS variable `--shadow-subtle` / `--shadow-elevated` or conditional `dark:shadow-*` utilities
|
||||
- Exceptions: Dench-specific decorative elements (landing page traffic-light dots, brand colors) can use fixed values
|
||||
245
.cursor/plans/mention_search_and_entry_modals_8a47eefc.plan.md
Normal file
245
.cursor/plans/mention_search_and_entry_modals_8a47eefc.plan.md
Normal file
@ -0,0 +1,245 @@
|
||||
---
|
||||
name: Mention Search and Entry Modals
|
||||
overview: Overhaul the tiptap @ mention system to search files AND object entries with fast fuzzy matching, fix the broken link system with canonical internal URIs, and add query-param-based entry detail modals accessible from table rows and @ mention links.
|
||||
todos:
|
||||
- id: search-index-api
|
||||
content: "Phase 1: Build GET /api/workspace/search-index endpoint -- returns flat JSON of all files + all entries from every DuckDB object with display fields"
|
||||
status: completed
|
||||
- id: fuse-search-hook
|
||||
content: "Phase 2: Add fuse.js dep, create useSearchIndex() hook in lib/search-index.ts, build Fuse instance on fetch"
|
||||
status: completed
|
||||
- id: mention-upgrade
|
||||
content: "Phase 2b: Rewrite createFileMention -> createWorkspaceMention in slash-command.tsx, wire up Fuse search, update CommandList for entry results"
|
||||
status: completed
|
||||
- id: link-utilities
|
||||
content: "Phase 3: Create lib/workspace-links.ts with parseWorkspaceLink/buildEntryLink, define @entry/{object}/{id} format"
|
||||
status: completed
|
||||
- id: link-insert-fix
|
||||
content: "Phase 3b: Update mention insert commands to use canonical link format; update markdown-editor.tsx link click handler to parse workspace links"
|
||||
status: completed
|
||||
- id: entry-api
|
||||
content: "Phase 4: Build GET /api/workspace/objects/[name]/entries/[id] endpoint for single entry data"
|
||||
status: completed
|
||||
- id: entry-modal
|
||||
content: "Phase 4b: Build EntryDetailModal component with full field display, relation links, close/escape handling"
|
||||
status: completed
|
||||
- id: query-param-routing
|
||||
content: "Phase 4c: Wire ?entry= query param in workspace/page.tsx -- render modal, sync URL on open/close, handle initial load"
|
||||
status: completed
|
||||
- id: table-row-click
|
||||
content: "Phase 4d: Make object-table.tsx rows clickable with onEntryClick prop, wire to entry param in parent"
|
||||
status: completed
|
||||
- id: fix-nav-bugs
|
||||
content: "Phase 5: Fix path resolution in onNavigate (knowledge/ prefix handling), sync URL bar with activePath via router.replace()"
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# @ Mention Search, Link System, and Entry Detail Modals
|
||||
|
||||
## Current Problems
|
||||
|
||||
1. **@ mention only searches files** -- entries (rows) in objects (tables) are invisible to the search. Uses naive `String.includes()` with no fuzzy matching.
|
||||
2. **File links are broken** -- `buildFileItems` in [slash-command.tsx](apps/web/app/components/workspace/slash-command.tsx) sets `href: node.path` (e.g. `knowledge/leads`), but `onNavigate` in [workspace/page.tsx](apps/web/app/workspace/page.tsx) calls `findNode(tree, path)` which may not resolve correctly depending on whether the path includes `knowledge/` prefix. No canonical link format exists.
|
||||
3. **No entry detail view** -- table rows in [object-table.tsx](apps/web/app/components/workspace/object-table.tsx) are not clickable. There is no modal, route, or UI to view a single entry.
|
||||
4. **No URL-based navigation** -- navigating to content is purely callback-based (state updates). The URL only updates on initial load via `?path=`. Sharing a link to a specific entry is impossible.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph searchIndex [Search Index - API]
|
||||
searchEndpoint["GET /api/workspace/search-index"]
|
||||
duckdb["DuckDB: objects + entries + fields"]
|
||||
tree["Filesystem: tree nodes"]
|
||||
searchEndpoint --> duckdb
|
||||
searchEndpoint --> tree
|
||||
end
|
||||
|
||||
subgraph clientSearch [Client-Side Fuzzy Search]
|
||||
fuseIndex["Fuse.js index - built once on load"]
|
||||
mentionPlugin["@ Mention Plugin"]
|
||||
fuseIndex --> mentionPlugin
|
||||
end
|
||||
|
||||
subgraph routing [Query Param Routing]
|
||||
pathParam["?path=knowledge/leads"]
|
||||
entryParam["&entry=abc123"]
|
||||
pathParam --> workspacePage["Workspace Page"]
|
||||
entryParam --> entryModal["Entry Detail Modal"]
|
||||
end
|
||||
|
||||
searchEndpoint -->|"JSON: files + entries"| fuseIndex
|
||||
mentionPlugin -->|"insert link"| internalLink["dench://entry/leads/abc123"]
|
||||
internalLink -->|"onNavigate resolves"| routing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Search Index API Endpoint
|
||||
|
||||
**New file:** `apps/web/app/api/workspace/search-index/route.ts`
|
||||
|
||||
Returns a flat JSON array of all searchable items -- both files and entry rows from every object. The client fetches this once on workspace load and rebuilds on tree changes (SSE watcher already triggers refreshes).
|
||||
|
||||
```typescript
|
||||
type SearchIndexItem = {
|
||||
// Shared
|
||||
id: string; // unique key (path for files, entryId for entries)
|
||||
label: string; // display text (filename or display-field value)
|
||||
sublabel?: string; // secondary text (path for files, object name for entries)
|
||||
kind: "file" | "object" | "entry";
|
||||
icon?: string;
|
||||
|
||||
// For entries
|
||||
objectName?: string;
|
||||
entryId?: string;
|
||||
fields?: Record<string, string>; // first 3-4 field key-value pairs for preview
|
||||
|
||||
// For files/objects
|
||||
path?: string;
|
||||
nodeType?: "document" | "folder" | "file" | "report" | "database";
|
||||
};
|
||||
```
|
||||
|
||||
**Server implementation:**
|
||||
|
||||
- Reuse existing `buildTree()` from [tree/route.ts](apps/web/app/api/workspace/tree/route.ts) for files
|
||||
- For entries: query every object from DuckDB, resolve display fields, and return flattened entries with their first few field values as searchable text
|
||||
- SQL: `SELECT * FROM v_{objectName} LIMIT 500` per object (capped for perf)
|
||||
- Cache with short TTL or rely on client refetch on SSE tree-change events
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Client-Side Fuzzy Search with Fuse.js
|
||||
|
||||
**New file:** `apps/web/lib/search-index.ts`
|
||||
|
||||
A React hook `useSearchIndex()` that:
|
||||
|
||||
1. Fetches `/api/workspace/search-index` once on mount
|
||||
2. Builds a `Fuse` instance over the items, keyed on `label`, `sublabel`, and `fields` values
|
||||
3. Exposes a `search(query: string): SearchIndexItem[]` function
|
||||
4. Rebuilds when tree changes (listen to same SSE watcher signal)
|
||||
|
||||
**Fuse.js config:**
|
||||
|
||||
- Keys: `["label", "sublabel", "objectName", "fields.*"]` with weighted scoring
|
||||
- Threshold: ~0.4 (tolerant fuzzy)
|
||||
- Max results: 20
|
||||
|
||||
**Update [slash-command.tsx](apps/web/app/components/workspace/slash-command.tsx):**
|
||||
|
||||
Replace `createFileMention(tree)` with `createWorkspaceMention(searchFn)`:
|
||||
|
||||
- `items` callback uses the Fuse search function instead of `flattenTree` + `.includes()`
|
||||
- Results are grouped: show files first, then entries grouped by object
|
||||
- The `CommandList` component gets a minor update to show entry items differently (object badge, field preview)
|
||||
|
||||
**Dep:** Add `fuse.js` to `apps/web/package.json` -- lightweight (~7KB gzipped), runs entirely client-side, no wasm.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Canonical Internal Link Format
|
||||
|
||||
Define a URI scheme for internal workspace links, parsed in a single utility:
|
||||
|
||||
**New file:** `apps/web/lib/workspace-links.ts`
|
||||
|
||||
```typescript
|
||||
// Canonical formats:
|
||||
// Files: "knowledge/path/to/doc.md" (relative path, unchanged)
|
||||
// Objects: "knowledge/leads" (relative path, unchanged)
|
||||
// Entries: "@entry/leads/abc123" (new: @entry/{objectName}/{entryId})
|
||||
|
||||
type WorkspaceLink =
|
||||
| { kind: "file"; path: string }
|
||||
| { kind: "object"; objectName: string; path: string }
|
||||
| { kind: "entry"; objectName: string; entryId: string };
|
||||
|
||||
function parseWorkspaceLink(href: string): WorkspaceLink;
|
||||
function buildEntryLink(objectName: string, entryId: string): string;
|
||||
function buildFileLink(path: string): string;
|
||||
function workspaceLinkToUrl(link: WorkspaceLink): string;
|
||||
// Returns: "/workspace?path=knowledge/leads" or "/workspace?path=knowledge/leads&entry=abc123"
|
||||
```
|
||||
|
||||
**Update mention insert command** in `slash-command.tsx`:
|
||||
|
||||
- File items: keep `href: node.path` (unchanged)
|
||||
- Entry items: use `href: "@entry/{objectName}/{entryId}"`, display text = display field value
|
||||
|
||||
**Update link click handler** in [markdown-editor.tsx](apps/web/app/components/workspace/markdown-editor.tsx) (lines 215-238):
|
||||
|
||||
- Parse the href with `parseWorkspaceLink()`
|
||||
- For file/object links: `onNavigate(link.path)` (as today, but path resolution fixed)
|
||||
- For entry links: `onNavigate("@entry/leads/abc123")` -- parent resolves to modal
|
||||
|
||||
**Update workspace page** `onNavigate` handler:
|
||||
|
||||
- Parse the link, and if it's an `@entry/...` link, set query params to open the entry modal
|
||||
- If it's a file/object link, do `findNode(tree, path)` as before but with proper path normalization
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Entry Detail Modal with Query Param Routing
|
||||
|
||||
**URL pattern:** `/workspace?path=knowledge/leads&entry=abc123`
|
||||
|
||||
When `entry` query param is present, render an `EntryDetailModal` as an overlay on top of the current workspace content.
|
||||
|
||||
**New file:** `apps/web/app/components/workspace/entry-detail-modal.tsx`
|
||||
|
||||
- Full-screen overlay/side panel with entry data
|
||||
- Fetches entry data from a new API endpoint or uses already-loaded object data
|
||||
- Renders all fields with proper type-specific display (reuse `CellValue` from [object-table.tsx](apps/web/app/components/workspace/object-table.tsx))
|
||||
- Relation fields are clickable -- clicking opens the related entry's modal (updates `entry` param)
|
||||
- Close button / Escape / clicking backdrop clears the `entry` param
|
||||
- URL is shareable and bookmarkable
|
||||
|
||||
**New API:** `GET /api/workspace/objects/[name]/entries/[id]`
|
||||
|
||||
- Returns a single entry with all field values, resolved relation labels, and reverse relations
|
||||
- Lightweight endpoint for the modal to fetch data without loading the full object
|
||||
|
||||
**Update [workspace/page.tsx](apps/web/app/workspace/page.tsx):**
|
||||
|
||||
- Read `entry` from `searchParams`
|
||||
- When present, render `<EntryDetailModal>` on top of current content
|
||||
- Update URL using `router.replace()` (shallow) when opening/closing modal -- no full page navigation
|
||||
- On initial load, if both `path` and `entry` are set, load the object AND open the modal
|
||||
|
||||
**Update [object-table.tsx](apps/web/app/components/workspace/object-table.tsx):**
|
||||
|
||||
- Make table rows clickable
|
||||
- `onClick` handler calls a new `onEntryClick(entryId)` prop
|
||||
- Parent sets the `entry` query param, opening the modal
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Fix Existing Link Navigation Bugs
|
||||
|
||||
**Bug 1:** Path resolution mismatch -- `node.path` from tree includes `knowledge/` prefix but link resolution sometimes strips it. Fix: normalize paths in `findNode()` and `onNavigate()` using the new `parseWorkspaceLink()`.
|
||||
|
||||
**Bug 2:** The `onNavigate` callback in `DocumentView` does `findNode(tree, path)` but the href from a markdown link may not exactly match a tree path (e.g., `leads` vs `knowledge/leads`). Fix: add fallback resolution that tries with/without `knowledge/` prefix, and tries matching the last segment against object names.
|
||||
|
||||
**Bug 3:** No URL update when navigating -- selecting a sidebar item or clicking a link updates state but not the URL bar. Fix: use `router.replace()` to keep URL in sync with `activePath` so links are shareable.
|
||||
|
||||
---
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
- [apps/web/app/components/workspace/slash-command.tsx](apps/web/app/components/workspace/slash-command.tsx) -- Replace file-only search with unified fuzzy search over files + entries
|
||||
- [apps/web/app/components/workspace/markdown-editor.tsx](apps/web/app/components/workspace/markdown-editor.tsx) -- Fix link click handler to support entry links
|
||||
- [apps/web/app/workspace/page.tsx](apps/web/app/workspace/page.tsx) -- Add entry modal rendering, fix URL sync, fix `onNavigate` resolution
|
||||
- [apps/web/app/components/workspace/object-table.tsx](apps/web/app/components/workspace/object-table.tsx) -- Make rows clickable with `onEntryClick`
|
||||
|
||||
## New Files
|
||||
|
||||
- `apps/web/app/api/workspace/search-index/route.ts` -- Search index endpoint
|
||||
- `apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts` -- Single entry endpoint
|
||||
- `apps/web/lib/workspace-links.ts` -- Link parsing/building utilities
|
||||
- `apps/web/lib/search-index.ts` -- Client-side Fuse.js hook
|
||||
- `apps/web/app/components/workspace/entry-detail-modal.tsx` -- Entry modal component
|
||||
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 |
|
||||
@ -46,3 +46,15 @@ Swabble/
|
||||
Core/
|
||||
Users/
|
||||
vendor/
|
||||
|
||||
# Needed for building the Canvas A2UI bundle during Docker image builds.
|
||||
# Keep the rest of apps/ and vendor/ excluded to avoid a large build context.
|
||||
!apps/shared/
|
||||
!apps/shared/OpenClawKit/
|
||||
!apps/shared/OpenClawKit/Tools/
|
||||
!apps/shared/OpenClawKit/Tools/CanvasA2UI/
|
||||
!apps/shared/OpenClawKit/Tools/CanvasA2UI/**
|
||||
!vendor/a2ui/
|
||||
!vendor/a2ui/renderers/
|
||||
!vendor/a2ui/renderers/lit/
|
||||
!vendor/a2ui/renderers/lit/**
|
||||
|
||||
75
.env.example
75
.env.example
@ -1,5 +1,70 @@
|
||||
# Copy to .env and fill with your Twilio credentials
|
||||
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
TWILIO_AUTH_TOKEN=your_auth_token_here
|
||||
# Must be a WhatsApp-enabled Twilio number, prefixed with whatsapp:
|
||||
TWILIO_WHATSAPP_FROM=whatsapp:+17343367101
|
||||
# OpenClaw .env example
|
||||
#
|
||||
# Quick start:
|
||||
# 1) Copy this file to `.env` (for local runs from this repo), OR to `~/.openclaw/.env` (for launchd/systemd daemons).
|
||||
# 2) Fill only the values you use.
|
||||
# 3) Keep real secrets out of git.
|
||||
#
|
||||
# Env-source precedence for environment variables (highest -> lowest):
|
||||
# process env, ./.env, ~/.openclaw/.env, then openclaw.json `env` block.
|
||||
# Existing non-empty process env vars are not overridden by dotenv/config env loading.
|
||||
# Note: direct config keys (for example `gateway.auth.token` or channel tokens in openclaw.json)
|
||||
# are resolved separately from env loading and often take precedence over env fallbacks.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Gateway auth + paths
|
||||
# -----------------------------------------------------------------------------
|
||||
# Recommended if the gateway binds beyond loopback.
|
||||
OPENCLAW_GATEWAY_TOKEN=change-me-to-a-long-random-token
|
||||
# Example generator: openssl rand -hex 32
|
||||
|
||||
# Optional alternative auth mode (use token OR password).
|
||||
# OPENCLAW_GATEWAY_PASSWORD=change-me-to-a-strong-password
|
||||
|
||||
# Optional path overrides (defaults shown for reference).
|
||||
# OPENCLAW_STATE_DIR=~/.openclaw
|
||||
# OPENCLAW_CONFIG_PATH=~/.openclaw/openclaw.json
|
||||
# OPENCLAW_HOME=~
|
||||
|
||||
# Optional: import missing keys from your login shell profile.
|
||||
# OPENCLAW_LOAD_SHELL_ENV=1
|
||||
# OPENCLAW_SHELL_ENV_TIMEOUT_MS=15000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Model provider API keys (set at least one)
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPENAI_API_KEY=sk-...
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
# GEMINI_API_KEY=...
|
||||
# OPENROUTER_API_KEY=sk-or-...
|
||||
|
||||
# Optional additional providers
|
||||
# ZAI_API_KEY=...
|
||||
# AI_GATEWAY_API_KEY=...
|
||||
# MINIMAX_API_KEY=...
|
||||
# SYNTHETIC_API_KEY=...
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Channels (only set what you enable)
|
||||
# -----------------------------------------------------------------------------
|
||||
# TELEGRAM_BOT_TOKEN=123456:ABCDEF...
|
||||
# DISCORD_BOT_TOKEN=...
|
||||
# SLACK_BOT_TOKEN=xoxb-...
|
||||
# SLACK_APP_TOKEN=xapp-...
|
||||
|
||||
# Optional channel env fallbacks
|
||||
# MATTERMOST_BOT_TOKEN=...
|
||||
# MATTERMOST_URL=https://chat.example.com
|
||||
# ZALO_BOT_TOKEN=...
|
||||
# OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:...
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tools + voice/media (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
# BRAVE_API_KEY=...
|
||||
# PERPLEXITY_API_KEY=pplx-...
|
||||
# FIRECRAWL_API_KEY=...
|
||||
|
||||
# ELEVENLABS_API_KEY=...
|
||||
# XI_API_KEY=... # alias for ElevenLabs
|
||||
# DEEPGRAM_API_KEY=...
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Onboarding
|
||||
url: https://discord.gg/clawd
|
||||
about: New to Clawdbot? Join Discord for setup guidance from Krill in \#help.
|
||||
about: New to OpenClaw? Join Discord for setup guidance from Krill in \#help.
|
||||
- name: Support
|
||||
url: https://discord.gg/clawd
|
||||
about: Get help from Krill and the community on Discord in \#help.
|
||||
|
||||
@ -8,6 +8,9 @@ outputs:
|
||||
docs_only:
|
||||
description: "'true' if all changes are docs/markdown, 'false' otherwise"
|
||||
value: ${{ steps.check.outputs.docs_only }}
|
||||
docs_changed:
|
||||
description: "'true' if any changed file is under docs/ or is markdown"
|
||||
value: ${{ steps.check.outputs.docs_changed }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@ -28,9 +31,18 @@ runs:
|
||||
CHANGED=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")
|
||||
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
|
||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if any changed file is a doc
|
||||
DOCS=$(echo "$CHANGED" | grep -E '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -n "$DOCS" ]; then
|
||||
echo "docs_changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Check if all changed files are docs or markdown
|
||||
NON_DOCS=$(echo "$CHANGED" | grep -vE '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -z "$NON_DOCS" ]; then
|
||||
83
.github/actions/setup-node-env/action.yml
vendored
Normal file
83
.github/actions/setup-node-env/action.yml
vendored
Normal file
@ -0,0 +1,83 @@
|
||||
name: Setup Node environment
|
||||
description: >
|
||||
Initialize submodules with retry, install Node 22, pnpm, optionally Bun,
|
||||
and run pnpm install. Requires actions/checkout to run first.
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version to install.
|
||||
required: false
|
||||
default: "22.x"
|
||||
pnpm-version:
|
||||
description: pnpm version for corepack.
|
||||
required: false
|
||||
default: "10.23.0"
|
||||
install-bun:
|
||||
description: Whether to install Bun alongside Node.
|
||||
required: false
|
||||
default: "true"
|
||||
frozen-lockfile:
|
||||
description: Whether to use --frozen-lockfile for install.
|
||||
required: false
|
||||
default: "true"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Checkout submodules (retry)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: ${{ inputs.pnpm-version }}
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Setup Bun
|
||||
if: inputs.install-bun == 'true'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Runtime versions
|
||||
shell: bash
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
if command -v bun &>/dev/null; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
shell: bash
|
||||
run: echo "NODE_BIN=$(dirname "$(node -p "process.execPath")")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
env:
|
||||
CI: "true"
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
LOCKFILE_FLAG=""
|
||||
if [ "${{ inputs.frozen-lockfile }}" = "true" ]; then
|
||||
LOCKFILE_FLAG="--frozen-lockfile"
|
||||
fi
|
||||
pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || \
|
||||
pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
26
.github/labeler.yml
vendored
26
.github/labeler.yml
vendored
@ -9,6 +9,11 @@
|
||||
- "src/discord/**"
|
||||
- "extensions/discord/**"
|
||||
- "docs/channels/discord.md"
|
||||
"channel: irc":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/irc/**"
|
||||
- "docs/channels/irc.md"
|
||||
"channel: feishu":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@ -79,6 +84,11 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/tlon/**"
|
||||
- "docs/channels/tlon.md"
|
||||
"channel: twitch":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/twitch/**"
|
||||
- "docs/channels/twitch.md"
|
||||
"channel: voice-call":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@ -226,3 +236,19 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qwen-portal-auth/**"
|
||||
"extensions: device-pair":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/device-pair/**"
|
||||
"extensions: minimax-portal-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/minimax-portal-auth/**"
|
||||
"extensions: phone-control":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/phone-control/**"
|
||||
"extensions: talk-voice":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/talk-voice/**"
|
||||
|
||||
35
.github/workflows/auto-response.yml
vendored
35
.github/workflows/auto-response.yml
vendored
@ -39,6 +39,11 @@ jobs:
|
||||
message:
|
||||
"Please use [our support server](https://discord.gg/clawd) and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.openclaw.ai/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
|
||||
},
|
||||
{
|
||||
label: "r: testflight",
|
||||
close: true,
|
||||
message: "Not available, build from source.",
|
||||
},
|
||||
{
|
||||
label: "r: third-party-extension",
|
||||
close: true,
|
||||
@ -60,10 +65,36 @@ jobs:
|
||||
const title = issue.title ?? "";
|
||||
const body = issue.body ?? "";
|
||||
const haystack = `${title}\n${body}`.toLowerCase();
|
||||
const hasLabel = (issue.labels ?? []).some((label) =>
|
||||
const hasMoltbookLabel = (issue.labels ?? []).some((label) =>
|
||||
typeof label === "string" ? label === "r: moltbook" : label?.name === "r: moltbook",
|
||||
);
|
||||
if (haystack.includes("moltbook") && !hasLabel) {
|
||||
const hasTestflightLabel = (issue.labels ?? []).some((label) =>
|
||||
typeof label === "string"
|
||||
? label === "r: testflight"
|
||||
: label?.name === "r: testflight",
|
||||
);
|
||||
const hasSecurityLabel = (issue.labels ?? []).some((label) =>
|
||||
typeof label === "string" ? label === "security" : label?.name === "security",
|
||||
);
|
||||
if (title.toLowerCase().includes("security") && !hasSecurityLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["security"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (title.toLowerCase().includes("testflight") && !hasTestflightLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["r: testflight"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (haystack.includes("moltbook") && !hasMoltbookLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
345
.github/workflows/ci.yml
vendored
345
.github/workflows/ci.yml
vendored
@ -16,6 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
docs_changed: ${{ steps.check.outputs.docs_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@ -25,7 +26,7 @@ jobs:
|
||||
|
||||
- name: Detect docs-only changes
|
||||
id: check
|
||||
uses: ./.github/actions/detect-docs-only
|
||||
uses: ./.github/actions/detect-docs-changes
|
||||
|
||||
# Detect which heavy areas are touched so PRs can skip unrelated expensive jobs.
|
||||
# Push to main keeps broad coverage.
|
||||
@ -83,6 +84,10 @@ jobs:
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
# Generated protocol models are already covered by protocol:check and
|
||||
# should not force the full native macOS lane.
|
||||
apps/macos/Sources/OpenClawProtocol/*|apps/shared/OpenClawKit/Sources/OpenClawProtocol/*)
|
||||
;;
|
||||
apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*)
|
||||
run_macos=true
|
||||
;;
|
||||
@ -120,7 +125,7 @@ jobs:
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
build-artifacts:
|
||||
needs: [docs-scope, changed-scope]
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
@ -129,49 +134,10 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies (frozen)
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
install-bun: "false"
|
||||
|
||||
- name: Build dist
|
||||
run: pnpm build
|
||||
@ -183,9 +149,10 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
install-check:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
# Validate npm pack contents after build (only on push to main, not PRs).
|
||||
release-check:
|
||||
needs: [docs-scope, build-artifacts]
|
||||
if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
@ -193,61 +160,28 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
install-bun: "false"
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies (frozen)
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
- name: Check release contents
|
||||
run: pnpm release:check
|
||||
|
||||
checks:
|
||||
needs: [docs-scope, changed-scope]
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: tsgo
|
||||
command: pnpm tsgo
|
||||
- runtime: node
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
@ -263,128 +197,46 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
# Lint and format always run, even on docs-only changes.
|
||||
checks-lint:
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
name: "check"
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: lint
|
||||
command: pnpm lint
|
||||
- task: format
|
||||
command: pnpm format
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Check types and lint and oxfmt
|
||||
run: pnpm check
|
||||
|
||||
# Validate docs (format, lint, broken links) only when docs files changed.
|
||||
check-docs:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_changed == 'true'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
submodules: false
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
- name: Check docs
|
||||
run: pnpm check:docs
|
||||
|
||||
secrets:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@ -412,7 +264,7 @@ jobs:
|
||||
fi
|
||||
|
||||
checks-windows:
|
||||
needs: [docs-scope, changed-scope, build-artifacts]
|
||||
needs: [docs-scope, changed-scope, build-artifacts, check]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
env:
|
||||
@ -461,19 +313,6 @@ jobs:
|
||||
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Download dist artifact (lint lane)
|
||||
if: matrix.task == 'lint'
|
||||
uses: actions/download-artifact@v4
|
||||
@ -533,7 +372,7 @@ jobs:
|
||||
# running 4 separate jobs per PR (as before) starved the queue. One job
|
||||
# per PR allows 5 PRs to run macOS checks simultaneously.
|
||||
macos:
|
||||
needs: [docs-scope, changed-scope]
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
@ -542,50 +381,10 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
# --- Node/pnpm setup (for TS tests) ---
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
install-bun: "false"
|
||||
|
||||
# --- Run all checks sequentially (fast gates first) ---
|
||||
- name: TS tests (macOS)
|
||||
@ -613,6 +412,14 @@ jobs:
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
|
||||
- name: Cache SwiftPM
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/Library/Caches/org.swift.swiftpm
|
||||
key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-swiftpm-
|
||||
|
||||
- name: Swift build (release)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@ -646,19 +453,6 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
@ -811,7 +605,7 @@ jobs:
|
||||
PY
|
||||
|
||||
android:
|
||||
needs: [docs-scope, changed-scope]
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
@ -828,19 +622,6 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
|
||||
14
.github/workflows/docker-release.yml
vendored
14
.github/workflows/docker-release.yml
vendored
@ -6,6 +6,12 @@ on:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
- "**/*.mdx"
|
||||
- ".agents/**"
|
||||
- "skills/**"
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
@ -56,8 +62,8 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
@ -105,8 +111,8 @@ jobs:
|
||||
platforms: linux/arm64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
|
||||
2
.github/workflows/install-smoke.yml
vendored
2
.github/workflows/install-smoke.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
- name: Detect docs-only changes
|
||||
id: check
|
||||
uses: ./.github/actions/detect-docs-only
|
||||
uses: ./.github/actions/detect-docs-changes
|
||||
|
||||
install-smoke:
|
||||
needs: [docs-scope]
|
||||
|
||||
137
.github/workflows/labeler.yml
vendored
137
.github/workflows/labeler.yml
vendored
@ -25,19 +25,120 @@ jobs:
|
||||
configuration-path: .github/labeler.yml
|
||||
repo-token: ${{ steps.app-token.outputs.token }}
|
||||
sync-labels: true
|
||||
- name: Apply PR size label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (!pullRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
|
||||
const labelColor = "fbca04";
|
||||
|
||||
for (const label of sizeLabels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
color: labelColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
||||
const totalChangedLines = files.reduce((total, file) => {
|
||||
const path = file.filename ?? "";
|
||||
if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
|
||||
return total;
|
||||
}
|
||||
return total + (file.additions ?? 0) + (file.deletions ?? 0);
|
||||
}, 0);
|
||||
|
||||
let targetSizeLabel = "size: XL";
|
||||
if (totalChangedLines < 50) {
|
||||
targetSizeLabel = "size: XS";
|
||||
} else if (totalChangedLines < 200) {
|
||||
targetSizeLabel = "size: S";
|
||||
} else if (totalChangedLines < 500) {
|
||||
targetSizeLabel = "size: M";
|
||||
} else if (totalChangedLines < 1000) {
|
||||
targetSizeLabel = "size: L";
|
||||
}
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
for (const label of currentLabels) {
|
||||
const name = label.name ?? "";
|
||||
if (!sizeLabels.includes(name)) {
|
||||
continue;
|
||||
}
|
||||
if (name === targetSizeLabel) {
|
||||
continue;
|
||||
}
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [targetSizeLabel],
|
||||
});
|
||||
- name: Apply maintainer label for org members
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const association = context.payload.pull_request?.author_association;
|
||||
if (!association) {
|
||||
const login = context.payload.pull_request?.user?.login;
|
||||
if (!login) {
|
||||
return;
|
||||
}
|
||||
if (![
|
||||
"MEMBER",
|
||||
"OWNER",
|
||||
].includes(association)) {
|
||||
|
||||
let isMaintainer = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: "maintainer",
|
||||
username: login,
|
||||
});
|
||||
isMaintainer = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMaintainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -62,14 +163,26 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const association = context.payload.issue?.author_association;
|
||||
if (!association) {
|
||||
const login = context.payload.issue?.user?.login;
|
||||
if (!login) {
|
||||
return;
|
||||
}
|
||||
if (![
|
||||
"MEMBER",
|
||||
"OWNER",
|
||||
].includes(association)) {
|
||||
|
||||
let isMaintainer = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: "maintainer",
|
||||
username: login,
|
||||
});
|
||||
isMaintainer = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMaintainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
51
.github/workflows/stale.yml
vendored
Normal file
51
.github/workflows/stale.yml
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Mark stale issues and pull requests
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token }}
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 5
|
||||
days-before-pr-stale: 5
|
||||
days-before-pr-close: 3
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
operations-per-run: 500
|
||||
exempt-all-assignees: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
stale-pr-message: |
|
||||
This pull request has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
|
||||
close-issue-reason: not_planned
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
|
||||
That channel is the escape hatch for high-quality PRs that get auto-closed.
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@ -3,11 +3,13 @@ node_modules
|
||||
.env
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
bun.lock
|
||||
bun.lockb
|
||||
coverage
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.tsbuildinfo
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
@ -16,6 +18,11 @@ ui/src/ui/__screenshots__/
|
||||
ui/playwright-report/
|
||||
ui/test-results/
|
||||
|
||||
# Android build artifacts
|
||||
apps/android/.gradle/
|
||||
apps/android/app/build/
|
||||
apps/android/.cxx/
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
apps/macos/.build/
|
||||
@ -52,7 +59,6 @@ apps/ios/fastlane/screenshots/
|
||||
apps/ios/fastlane/test_output/
|
||||
apps/ios/fastlane/logs/
|
||||
apps/ios/fastlane/.env
|
||||
apps/ios/fastlane/report.xml
|
||||
|
||||
# fastlane build artifacts (local)
|
||||
apps/ios/*.ipa
|
||||
@ -60,10 +66,10 @@ apps/ios/*.dSYM.zip
|
||||
|
||||
# provisioning profiles (local)
|
||||
apps/ios/*.mobileprovision
|
||||
.env
|
||||
|
||||
# Local untracked files
|
||||
.local/
|
||||
docs/.local/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
.tgz
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"globs": ["docs/**/*.md", "docs/**/*.mdx", "README.md"],
|
||||
"ignores": ["docs/zh-CN/**", "docs/.i18n/**", "docs/reference/templates/**"],
|
||||
"ignores": ["docs/zh-CN/**", "docs/.i18n/**", "docs/reference/templates/**", "**/.local/**"],
|
||||
"config": {
|
||||
"default": true,
|
||||
|
||||
|
||||
@ -11,8 +11,10 @@ Input
|
||||
Do (end-to-end)
|
||||
Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` with `--rebase` or `--squash`.
|
||||
|
||||
1. Repo clean: `git status`.
|
||||
2. Identify PR meta (author + head branch):
|
||||
1. Assign PR to self:
|
||||
- `gh pr edit <PR> --add-assignee @me`
|
||||
2. Repo clean: `git status`.
|
||||
3. Identify PR meta (author + head branch):
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner}'
|
||||
@ -21,50 +23,50 @@ Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` wit
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
```
|
||||
|
||||
3. Fast-forward base:
|
||||
4. Fast-forward base:
|
||||
- `git checkout main`
|
||||
- `git pull --ff-only`
|
||||
4. Create temp base branch from main:
|
||||
5. Create temp base branch from main:
|
||||
- `git checkout -b temp/landpr-<ts-or-pr>`
|
||||
5. Check out PR branch locally:
|
||||
6. Check out PR branch locally:
|
||||
- `gh pr checkout <PR>`
|
||||
6. Rebase PR branch onto temp base:
|
||||
7. Rebase PR branch onto temp base:
|
||||
- `git rebase temp/landpr-<ts-or-pr>`
|
||||
- Fix conflicts; keep history tidy.
|
||||
7. Fix + tests + changelog:
|
||||
8. Fix + tests + changelog:
|
||||
- Implement fixes + add/adjust tests
|
||||
- Update `CHANGELOG.md` and mention `#<PR>` + `@$contrib`
|
||||
8. Decide merge strategy:
|
||||
9. Decide merge strategy:
|
||||
- Rebase if we want to preserve commit history
|
||||
- Squash if we want a single clean commit
|
||||
- If unclear, ask
|
||||
9. Full gate (BEFORE commit):
|
||||
- `pnpm lint && pnpm build && pnpm test`
|
||||
10. Commit via committer (include # + contributor in commit message):
|
||||
10. Full gate (BEFORE commit):
|
||||
- `pnpm lint && pnpm build && pnpm test`
|
||||
11. Commit via committer (include # + contributor in commit message):
|
||||
- `committer "fix: <summary> (#<PR>) (thanks @$contrib)" CHANGELOG.md <changed files>`
|
||||
- `land_sha=$(git rev-parse HEAD)`
|
||||
11. Push updated PR branch (rebase => usually needs force):
|
||||
12. Push updated PR branch (rebase => usually needs force):
|
||||
|
||||
```sh
|
||||
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
|
||||
git push --force-with-lease prhead HEAD:$head
|
||||
```
|
||||
|
||||
12. Merge PR (must show MERGED on GitHub):
|
||||
13. Merge PR (must show MERGED on GitHub):
|
||||
- Rebase: `gh pr merge <PR> --rebase`
|
||||
- Squash: `gh pr merge <PR> --squash`
|
||||
- Never `gh pr close` (closing is wrong)
|
||||
13. Sync main:
|
||||
14. Sync main:
|
||||
- `git checkout main`
|
||||
- `git pull --ff-only`
|
||||
14. Comment on PR with what we did + SHAs + thanks:
|
||||
15. Comment on PR with what we did + SHAs + thanks:
|
||||
|
||||
```sh
|
||||
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
|
||||
gh pr comment <PR> --body "Landed via temp rebase onto main.\n\n- Gate: pnpm lint && pnpm build && pnpm test\n- Land commit: $land_sha\n- Merge commit: $merge_sha\n\nThanks @$contrib!"
|
||||
```
|
||||
|
||||
15. Verify PR state == MERGED:
|
||||
16. Verify PR state == MERGED:
|
||||
- `gh pr view <PR> --json state --jq .state`
|
||||
16. Delete temp branch:
|
||||
17. Delete temp branch:
|
||||
- `git branch -D temp/landpr-<ts-or-pr>`
|
||||
|
||||
27
AGENTS.md
27
AGENTS.md
@ -15,12 +15,13 @@
|
||||
- Core channel docs: `docs/channels/`
|
||||
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
|
||||
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
|
||||
- When adding channels/extensions/apps/docs, review `.github/labeler.yml` for label coverage.
|
||||
- When adding channels/extensions/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/extension label colors).
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
|
||||
- Docs are hosted on Mintlify (docs.openclaw.ai).
|
||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||
- When working with documentation, read the mintlify skill.
|
||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
|
||||
- When Peter asks for links, reply with full `https://docs.openclaw.ai/...` URLs (not root-relative).
|
||||
@ -60,6 +61,8 @@
|
||||
- Type-check/build: `pnpm build`
|
||||
- TypeScript checks: `pnpm tsgo`
|
||||
- Lint/format: `pnpm check`
|
||||
- Format check: `pnpm format` (oxfmt --check)
|
||||
- Format fix: `pnpm format:fix` (oxfmt --write)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
@ -90,34 +93,18 @@
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
**Full maintainer PR workflow:** `.agents/skills/PR_WORKFLOW.md` -- triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the 3-step skill pipeline (`review-pr` > `prepare-pr` > `merge-pr`).
|
||||
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- Read this when submitting a PR: `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr))
|
||||
- Read this when submitting an issue: `docs/help/submitting-an-issue.md` ([Submitting an Issue](https://docs.openclaw.ai/help/submitting-an-issue))
|
||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
||||
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
|
||||
- Goal: merge PRs. Prefer **rebase** when commits are clean; **squash** when history is messy.
|
||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
||||
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
||||
- When working on an issue: reference the issue in the changelog entry.
|
||||
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
|
||||
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
||||
|
||||
## Shorthand Commands
|
||||
|
||||
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
|
||||
### PR Workflow (Review vs Land)
|
||||
|
||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
|
||||
@ -134,6 +121,7 @@
|
||||
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`).
|
||||
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
@ -148,6 +136,7 @@
|
||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
|
||||
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
|
||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
|
||||
139
CHANGELOG.md
139
CHANGELOG.md
@ -2,53 +2,124 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.6-4
|
||||
## 2026.2.10
|
||||
|
||||
### Added
|
||||
### Changes
|
||||
|
||||
- Gateway: add `agents.create`, `agents.update`, `agents.delete` RPC methods for web UI agent management. (#11045) Thanks @advaitpaliwal.
|
||||
- Gateway: add node command allowlists (default-deny unknown node commands; configurable via `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`). (#11755) Thanks @mbelinky.
|
||||
- Plugins: add `device-pair` (Telegram `/pair` flow) and `phone-control` (iOS/Android node controls). (#11755) Thanks @mbelinky.
|
||||
- iOS: add alpha iOS node app (Telegram setup-code pairing + Talk/Chat surfaces). (#11756) Thanks @mbelinky.
|
||||
- Docs: seed initial ja-JP translations (POC) and make docs-i18n prompts language-pluggable for Japanese. (#11988) Thanks @joshp123.
|
||||
- Paths: add `OPENCLAW_HOME` environment variable for overriding the home directory used by all internal path resolution. (#12091) Thanks @sebslight.
|
||||
- Version alignment: bump manifests and package versions to `2026.2.10`; keep `appcast.xml` unchanged until the next macOS release cut.
|
||||
- CLI: add `openclaw logs --local-time` to display log timestamps in local timezone. (#13818) Thanks @xialonglee.
|
||||
- Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Exec approvals: format forwarded command text as inline/fenced monospace for safer approval scanning across channels. (#11937)
|
||||
- Config: clamp `maxTokens` to `contextWindow` to prevent invalid model configs. (#5516) Thanks @lailoo.
|
||||
- Docs: fix language switcher ordering and Japanese locale flag in Mintlify nav. (#12023) Thanks @joshp123.
|
||||
- Paths: make internal path resolution respect `HOME`/`USERPROFILE` before `os.homedir()` across config, agents, sessions, pairing, cron, and CLI profiles. (#12091) Thanks @sebslight.
|
||||
- Paths: structurally resolve `OPENCLAW_HOME`-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.
|
||||
- Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @seans-openclawbot.
|
||||
- Discord: support forum/media `thread create` starter messages, wire `message thread create --message`, and harden thread-create routing. (#10062) Thanks @jarvis89757.
|
||||
- Gateway: stabilize chat routing by canonicalizing node session keys for node-originated chat methods. (#11755) Thanks @mbelinky.
|
||||
- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.
|
||||
- Cron: route text-only isolated agent announces through the shared subagent announce flow; add exponential backoff for repeated errors; preserve future `nextRunAtMs` on restart; include current-boundary schedule matches; prevent stale threadId reuse across targets; and add per-job execution timeout. (#11641) Thanks @tyler6204.
|
||||
- Subagents: stabilize announce timing, preserve compaction metrics across retries, clamp overflow-prone long timeouts, and cap impossible context usage token totals. (#11551) Thanks @tyler6204.
|
||||
- Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.
|
||||
- Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session `parentId` chain so agents can remember again. Thanks @Takhoffman 🦞.
|
||||
- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini.
|
||||
- Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon.
|
||||
- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro.
|
||||
- Cron: re-arm timers when `onTimer` fires while a job is still executing. (#14233) Thanks @tomron87.
|
||||
- Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.
|
||||
- Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.
|
||||
- Cron: prevent one-shot `at` jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.
|
||||
- WhatsApp: convert Markdown bold/strikethrough to WhatsApp formatting. (#14285) Thanks @Raikan10.
|
||||
- WhatsApp: allow media-only sends and normalize leading blank payloads. (#14408) Thanks @karimnaguib.
|
||||
- WhatsApp: default MIME type for voice messages when Baileys omits it. (#14444) Thanks @mcaxtr.
|
||||
- Ollama: use configured `models.providers.ollama.baseUrl` for model discovery and normalize `/v1` endpoints to the native Ollama API root. (#14131) Thanks @shtse8.
|
||||
- Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker.
|
||||
- Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.
|
||||
- CLI/Wizard: exit with code 1 when `configure`, `agents add`, or interactive `onboard` wizards are canceled, so `set -e` automation stops correctly. (#14156) Thanks @0xRaini.
|
||||
- Feishu: pass `Buffer` directly to the Feishu SDK upload APIs instead of `Readable.from(...)` to avoid form-data upload failures. (#10345) Thanks @youngerstyle.
|
||||
- Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.
|
||||
- Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.
|
||||
- Feishu DocX: preserve top-level converted block order using `firstLevelBlockIds` when writing/appending documents. (#13994) Thanks @Cynosure159.
|
||||
- Feishu plugin packaging: remove `workspace:*` `openclaw` dependency from `extensions/feishu` and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015.
|
||||
- Telegram: handle no-text message in model picker editMessageText. (#14397) Thanks @0xRaini.
|
||||
- Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.
|
||||
- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.
|
||||
- Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini.
|
||||
|
||||
## 2026.2.9
|
||||
|
||||
### Added
|
||||
|
||||
- Commands: add `commands.allowFrom` config for separate command authorization, allowing operators to restrict slash commands to specific users while keeping chat open to others. (#12430) Thanks @thewilloftheshadow.
|
||||
- Docker: add ClawDock shell helpers for Docker workflows. (#12817) Thanks @Olshansk.
|
||||
- iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.
|
||||
- Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.
|
||||
- Channels: IRC first-class channel support. (#11482) Thanks @vignesh07.
|
||||
- Plugins: device pairing + phone control plugins (Telegram `/pair`, iOS/Android node controls). (#11755) Thanks @mbelinky.
|
||||
- Tools: add Grok (xAI) as a `web_search` provider. (#12419) Thanks @tmchow.
|
||||
- Gateway: add agent management RPC methods for the web UI (`agents.create`, `agents.update`, `agents.delete`). (#11045) Thanks @advaitpaliwal.
|
||||
- Gateway: stream thinking events to WS clients and broadcast tool events independent of verbose level. (#10568) Thanks @nk1tz.
|
||||
- Web UI: show a Compaction divider in chat history. (#11341) Thanks @Takhoffman.
|
||||
- Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman.
|
||||
- Agents: auto-select `zai/glm-4.6v` for image understanding when ZAI is primary provider. (#10267) Thanks @liuy.
|
||||
- Paths: add `OPENCLAW_HOME` for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight.
|
||||
- Onboarding: add Custom Provider flow for OpenAI and Anthropic-compatible endpoints. (#11106) Thanks @MackDing.
|
||||
- Hooks: route webhook agent runs to specific `agentId`s, add `hooks.allowedAgentIds` controls, and fall back to default agent when unknown IDs are provided. (#13672) Thanks @BillChirico.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Cron: prevent one-shot `at` jobs from re-firing on gateway restart when previously skipped or errored. (#13845)
|
||||
- Discord: add exec approval cleanup option to delete DMs after approval/denial/timeout. (#13205) Thanks @thewilloftheshadow.
|
||||
- Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @Glucksberg, @gumadeiras.
|
||||
- CI: Implement pipeline and workflow order. Thanks @quotentiroler.
|
||||
- WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez.
|
||||
- Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.
|
||||
- Telegram: recover proactive sends when stale topic thread IDs are used by retrying without `message_thread_id`. (#11620)
|
||||
- Discord: auto-create forum/media thread posts on send, with chunked follow-up replies and media handling for forum sends. (#12380) Thanks @magendary, @thewilloftheshadow.
|
||||
- Discord: cap gateway reconnect attempts to avoid infinite retry loops. (#12230) Thanks @Yida-Dev.
|
||||
- Telegram: render markdown spoilers with `<tg-spoiler>` HTML tags. (#11543) Thanks @ezhikkk.
|
||||
- Telegram: recover proactive sends when stale topic thread IDs are used by retrying without `message_thread_id`, and clear explicit no-thread route updates instead of inheriting stale thread state. (#11620)
|
||||
- Telegram: truncate command registration to 100 entries to avoid `BOT_COMMANDS_TOO_MUCH` failures on startup. (#12356) Thanks @arosstale.
|
||||
- Telegram: match DM `allowFrom` against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai.
|
||||
- Pairing/Telegram: include the actual pairing code in approve commands, route Telegram pairing replies through the shared pairing message builder, and add regression checks to prevent `<code>` placeholder drift.
|
||||
- Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual).
|
||||
- Onboarding/Providers: add LiteLLM provider onboarding and preserve custom LiteLLM proxy base URLs while enforcing API-key auth mode. (#12823) Thanks @ryan-crabbe.
|
||||
- Docker: make `docker-setup.sh` compatible with macOS Bash 3.2 and empty extra mounts. (#9441) Thanks @mateusz-michalik.
|
||||
- Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials.
|
||||
- Agents: strip reasoning tags and downgraded tool markers from messaging tool and streaming output to prevent leakage. (#11053, #13453) Thanks @liebertar, @meaadore1221-afk, @gumadeiras.
|
||||
- Browser: prevent stuck `act:evaluate` from wedging the browser tool, and make cancellation stop waiting promptly. (#13498) Thanks @onutc.
|
||||
- Security/Gateway: default-deny missing connect `scopes` (no implicit `operator.admin`).
|
||||
- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.
|
||||
- Web UI: coerce Form Editor values to schema types before `config.set` and `config.apply`, preventing numeric and boolean fields from being serialized as strings. (#13468) Thanks @mcaxtr.
|
||||
- Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow.
|
||||
- Tools/web_search: fix Grok response parsing for xAI Responses API output blocks. (#13049) Thanks @ereid7.
|
||||
- Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.
|
||||
- Model failover: treat HTTP 400 errors as failover-eligible, enabling automatic model fallback. (#1879) Thanks @orenyomtov.
|
||||
- Errors: prevent false positive context overflow detection when conversation mentions "context overflow" topic. (#2078) Thanks @sbking.
|
||||
- Errors: avoid rewriting/swallowing normal assistant replies that mention error keywords by scoping `sanitizeUserFacingText` rewrites to error-context. (#12988) Thanks @Takhoffman.
|
||||
- Config: re-hydrate state-dir `.env` during runtime config loads so `${VAR}` substitutions remain resolvable. (#12748) Thanks @rodrigouroz.
|
||||
- Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session `parentId` chain so agents can remember again. (#12283) Thanks @Takhoffman.
|
||||
- Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman.
|
||||
- Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.
|
||||
- Subagents/compaction: stabilize announce timing and preserve compaction metrics across retries. (#11664) Thanks @tyler6204.
|
||||
- Subagents: report timeout-aborted runs as timed out instead of completed successfully in parent-session announcements. (#13996) Thanks @dario-github.
|
||||
- Cron: share isolated announce flow and harden scheduling/delivery reliability. (#11641) Thanks @tyler6204.
|
||||
- Cron tool: recover flat params when LLM omits the `job` wrapper for add requests. (#12124) Thanks @tyler6204.
|
||||
- Gateway/CLI: when `gateway.bind=lan`, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.
|
||||
- CLI: make `openclaw plugins list` output scannable by hoisting source roots and shortening bundled/global/workspace plugin paths.
|
||||
- Hooks: fix bundled hooks broken since 2026.2.2 (tsdown migration). (#9295) Thanks @patrickshao.
|
||||
- Security/Plugins: install plugin and hook dependencies with `--ignore-scripts` to prevent lifecycle script execution.
|
||||
- Routing: refresh bindings per message by loading config at route resolution so binding changes apply without restart. (#11372) Thanks @juanpablodlc.
|
||||
- Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight.
|
||||
- Config: clamp `maxTokens` to `contextWindow` to prevent invalid model configs. (#5516) Thanks @lailoo.
|
||||
- Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @LatencyTDH.
|
||||
- Thinking: honor `/think off` for reasoning-capable models. (#9564) Thanks @liuy.
|
||||
- Discord: support forum/media thread-create starter messages, wire `message thread create --message`, and harden routing. (#10062) Thanks @jarvis89757.
|
||||
- Paths: structurally resolve `OPENCLAW_HOME`-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.
|
||||
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
|
||||
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, and retry QMD after fallback failures. (#9690, #9705)
|
||||
- Memory/QMD: log explicit warnings when `memory.qmd.scope` blocks a search request. (#10191)
|
||||
- Memory: disable async batch embeddings by default for memory indexing (opt-in via `agents.defaults.memorySearch.remote.batch.enabled`). (#13069) Thanks @mcinteerj.
|
||||
- Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204.
|
||||
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07.
|
||||
- Memory/QMD: initialize QMD backend on gateway startup so background update timers restart after process reloads. (#10797) Thanks @vignesh07.
|
||||
- Config/Memory: auto-migrate legacy top-level `memorySearch` settings into `agents.defaults.memorySearch`. (#11278, #9143) Thanks @vignesh07.
|
||||
- Memory/QMD: treat plain-text `No results found` output from QMD as an empty result instead of throwing invalid JSON errors. (#9824)
|
||||
- Memory/QMD: add `memory.qmd.searchMode` to choose `query`, `search`, or `vsearch` recall mode. (#9967, #10084)
|
||||
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
|
||||
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
|
||||
- Doctor/State dir: suppress repeated legacy migration warnings only for valid symlink mirrors, while keeping warnings for empty or invalid legacy trees. (#11709) Thanks @gumadeiras.
|
||||
- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras.
|
||||
- macOS: honor Nix-managed defaults suite (`ai.openclaw.mac`) for nixMode to prevent onboarding from reappearing after bundle-id churn. (#12205) Thanks @joshp123.
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
### Changes
|
||||
|
||||
- Hygiene: remove `workspace:*` from `dependencies` in msteams, nostr, zalo extensions (breaks external `npm install`; keep in `devDependencies` only).
|
||||
- Hygiene: add non-root `sandbox` user to `Dockerfile.sandbox` and `Dockerfile.sandbox-browser`.
|
||||
- Hygiene: remove dead `vitest` key from `package.json` (superseded by `vitest.config.ts`).
|
||||
- Hygiene: remove redundant top-level `overrides` from `package.json` (pnpm uses `pnpm.overrides`).
|
||||
- Hygiene: sync `onlyBuiltDependencies` between `pnpm-workspace.yaml` and `package.json` (add missing `node-llama-cpp`, sort alphabetically).
|
||||
- Cron: default `wakeMode` is now `"now"` for new jobs (was `"next-heartbeat"`). (#10776) Thanks @tyler6204.
|
||||
- Cron: `cron run` defaults to force execution; use `--due` to restrict to due-only. (#10776) Thanks @tyler6204.
|
||||
- Models: support Anthropic Opus 4.6 and OpenAI Codex gpt-5.3-codex (forward-compat fallbacks). (#9853, #10720, #9995) Thanks @TinyTb, @calvin-hpnet, @tyler6204.
|
||||
@ -71,6 +142,9 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204.
|
||||
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
|
||||
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
|
||||
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07.
|
||||
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
|
||||
- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi.
|
||||
- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek.
|
||||
- Cron: fix scheduling and reminder delivery regressions; harden next-run recompute + timer re-arming + legacy schedule fields. (#9733, #9823, #9948, #9932) Thanks @tyler6204, @pycckuu, @j2h4u, @fujiwara-tofu-shop.
|
||||
@ -121,6 +195,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.
|
||||
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
|
||||
- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.
|
||||
- Discord: enforce DM allowlists for agent components (buttons/select menus), honoring pairing store approvals and tag matches. (#11254) Thanks @thedudeabidesai.
|
||||
|
||||
## 2026.2.2-3
|
||||
|
||||
|
||||
@ -16,6 +16,9 @@ Welcome to the lobster tank! 🦞
|
||||
- **Shadow** - Discord + Slack subsystem
|
||||
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
|
||||
- **Vignesh** - Memory (QMD), formal modeling, TUI, and Lobster
|
||||
- GitHub: [@vignesh07](https://github.com/vignesh07) · X: [@\_vgnsh](https://x.com/_vgnsh)
|
||||
|
||||
- **Jos** - Telegram, API, Nix mode
|
||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||
|
||||
@ -25,6 +28,9 @@ Welcome to the lobster tank! 🦞
|
||||
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
|
||||
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
|
||||
|
||||
- **Maximilian Nussbaumer** - DevOps, CI, Code Sanity
|
||||
- GitHub: [@quotentiroler](https://github.com/quotentiroler) · X: [@quotentiroler](https://x.com/quotentiroler)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
@ -35,6 +41,7 @@ Welcome to the lobster tank! 🦞
|
||||
|
||||
- Test locally with your OpenClaw instance
|
||||
- Run tests: `pnpm build && pnpm check && pnpm test`
|
||||
- Ensure CI checks pass
|
||||
- Keep PRs focused (one thing per PR)
|
||||
- Describe what & why
|
||||
|
||||
@ -72,7 +79,33 @@ We are currently prioritizing:
|
||||
|
||||
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
|
||||
- **UX**: Improving the onboarding wizard and error messages.
|
||||
- **Skills**: Expanding the library of bundled skills and improving the Skill Creation developer experience.
|
||||
- **Skills**: For skill contributions, head to [ClawHub](https://clawhub.ai/) — the community hub for OpenClaw skills.
|
||||
- **Performance**: Optimizing token usage and compaction logic.
|
||||
|
||||
Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels!
|
||||
|
||||
## Report a Vulnerability
|
||||
|
||||
We take security reports seriously. Report vulnerabilities directly to the repository where the issue lives:
|
||||
|
||||
- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw)
|
||||
- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos)
|
||||
- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios)
|
||||
- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android)
|
||||
- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub)
|
||||
- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust)
|
||||
|
||||
For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it.
|
||||
|
||||
### Required in Reports
|
||||
|
||||
1. **Title**
|
||||
2. **Severity Assessment**
|
||||
3. **Impact**
|
||||
4. **Affected Component**
|
||||
5. **Technical Reproduction**
|
||||
6. **Demonstrated Impact**
|
||||
7. **Environment**
|
||||
8. **Remediation Advice**
|
||||
|
||||
Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues.
|
||||
|
||||
@ -24,7 +24,7 @@ COPY scripts ./scripts
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN OPENCLAW_A2UI_SKIP_MISSING=1 pnpm build
|
||||
RUN pnpm build
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm ui:build
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
703
README.md
703
README.md
@ -1,396 +1,89 @@
|
||||
# 🦞 OpenClaw AI SDK
|
||||
# Ironclaw
|
||||
|
||||
**AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management.**
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/openclaw-ai-sdk-banner.png" alt="OpenClaw + Vercel AI SDK v6" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Clawdbot's future-compatible fork using Vercel's AI SDK by default</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/kumarabhirup/openclaw-ai-sdk/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/kumarabhirup/openclaw-ai-sdk/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/kumarabhirup/openclaw-ai-sdk/releases"><img src="https://img.shields.io/github/v/release/kumarabhirup/openclaw-ai-sdk?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||
<a href="https://www.npmjs.com/package/ironclaw"><img src="https://img.shields.io/npm/v/ironclaw?style=for-the-badge&color=000" alt="npm version"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
> **This is a fork of [OpenClaw](https://github.com/openclaw/openclaw)** that uses **Vercel's AI SDK v6** by default instead of the pi-mono agent. Dual SDK support makes this useful for developers in the Vercel ecosystem using `useChat()` and AI SDK v5/6 primitives.
|
||||
---
|
||||
|
||||
**OpenClaw** is a _personal AI assistant_ you run on your own devices.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
Ironclaw is a personal AI assistant and CRM toolkit that runs on your own devices. It connects to your existing messaging channels (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, and more), manages structured data through a DuckDB-powered workspace, and provides a rich web interface for knowledge management and reporting.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
Built on [OpenClaw](https://github.com/openclaw/openclaw) with **Vercel AI SDK v6** as the default LLM orchestration layer.
|
||||
|
||||
<<<<<<< HEAD
|
||||
## Features
|
||||
|
||||
## Why This Fork?
|
||||
- **Multi-channel inbox** -- WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, Matrix, WebChat, and more.
|
||||
- **DuckDB workspace** -- Structured data objects, file management, full-text search, and bulk operations through a local DuckDB-backed store.
|
||||
- **Web UI (Dench)** -- Modern chat interface with chain-of-thought reasoning, report cards, media viewer, and a database explorer. Supports light and dark themes.
|
||||
- **Agent gateway** -- Local-first WebSocket control plane for sessions, channels, tools, and events. Routes agent execution through lane-based concurrency.
|
||||
- **Vercel AI SDK v6** -- Default LLM engine with support for Anthropic, OpenAI, Google, Groq, Mistral, xAI, OpenRouter, and Azure. Full extended thinking/reasoning support.
|
||||
- **Knowledge management** -- File tree, search index, workspace objects with custom fields, and entry-level detail views.
|
||||
- **TanStack data tables** -- Sortable, filterable, bulk-selectable tables for workspace objects powered by `@tanstack/react-table`.
|
||||
- **Companion apps** -- macOS menu bar app, iOS/Android nodes with voice, camera, and canvas capabilities.
|
||||
- **Skills platform** -- Bundled, managed, and workspace-scoped skills with install gating.
|
||||
|
||||
| Feature | Original OpenClaw | This Fork (AI SDK) |
|
||||
| ---------------------- | ------------------ | ------------------------------------------------ |
|
||||
| LLM Orchestration | pi-mono agent only | **Vercel AI SDK v6** (default) and pi-mono agent |
|
||||
| Dual Engine Support | No | **Yes** - switch via config |
|
||||
| `useChat()` Compatible | No | **Yes** |
|
||||
## Install
|
||||
|
||||
# [Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-clawdbot) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
**Runtime: Node 22+**
|
||||
|
||||
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
|
||||
> > > > > > > 0cf93b8fa74566258131f9e8ca30f313aac89d26
|
||||
|
||||
Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal.
|
||||
The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Works with npm, pnpm, or bun.
|
||||
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
**Subscriptions (OAuth):**
|
||||
|
||||
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
- Models config + CLI: [Models](https://docs.openclaw.ai/concepts/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.openclaw.ai/concepts/model-failover)
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
### From this fork (AI SDK version)
|
||||
### From npm
|
||||
|
||||
```bash
|
||||
npm install -g ironclaw@latest
|
||||
|
||||
ironclaw onboard --install-daemon
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
# Clone this fork
|
||||
git clone https://github.com/kumarabhirup/openclaw-ai-sdk.git
|
||||
cd openclaw-ai-sdk
|
||||
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
pnpm openclaw onboard --install-daemon
|
||||
pnpm dev onboard --install-daemon
|
||||
```
|
||||
|
||||
### From npm (original OpenClaw)
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
# or: pnpm add -g openclaw@latest
|
||||
|
||||
openclaw onboard --install-daemon
|
||||
```
|
||||
|
||||
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
|
||||
|
||||
## AI SDK Configuration
|
||||
|
||||
This fork defaults to the **AI SDK engine**. You can switch between engines:
|
||||
|
||||
```bash
|
||||
# During setup, choose your preferred engine
|
||||
openclaw configure
|
||||
|
||||
# Or set directly in config
|
||||
openclaw config set agents.engine aisdk # Use AI SDK (default)
|
||||
openclaw config set agents.engine pi-agent # Use original pi-agent
|
||||
```
|
||||
|
||||
### Supported Providers (AI SDK)
|
||||
|
||||
| Provider | Environment Variable | Models |
|
||||
| ---------- | ------------------------------ | --------------------------------- |
|
||||
| Anthropic | `ANTHROPIC_API_KEY` | Claude 4/3.x, Opus, Sonnet, Haiku |
|
||||
| OpenAI | `OPENAI_API_KEY` | GPT-4o, GPT-4, o1, o3 |
|
||||
| Google | `GOOGLE_GENERATIVE_AI_API_KEY` | Gemini 2.x, 1.5 Pro |
|
||||
| AI Gateway | `AI_GATEWAY_API_KEY` | All providers via Vercel |
|
||||
| OpenRouter | `OPENROUTER_API_KEY` | 100+ models |
|
||||
| Azure | `AZURE_OPENAI_API_KEY` | Azure OpenAI models |
|
||||
| Groq | `GROQ_API_KEY` | Llama, Mixtral |
|
||||
| Mistral | `MISTRAL_API_KEY` | Mistral models |
|
||||
| xAI | `XAI_API_KEY` | Grok models |
|
||||
|
||||
### Anthropic Thinking/Reasoning
|
||||
|
||||
This fork fully supports [Anthropic's extended thinking](https://ai-sdk.dev/providers/ai-sdk-providers/anthropic#reasoning):
|
||||
|
||||
```bash
|
||||
# Set thinking level (maps to AI SDK budgetTokens)
|
||||
openclaw agent --message "Complex task" --thinking high
|
||||
|
||||
# Thinking levels: off, minimal, low, medium, high, xhigh
|
||||
# xhigh uses 32K budget tokens + effort: high for Opus 4.5
|
||||
```
|
||||
|
||||
## Quick start (TL;DR)
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
```bash
|
||||
openclaw onboard --install-daemon
|
||||
|
||||
openclaw gateway --port 18789 --verbose
|
||||
# Start the gateway
|
||||
ironclaw gateway --port 18789 --verbose
|
||||
|
||||
# Send a message
|
||||
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
|
||||
ironclaw message send --to +1234567890 --message "Hello from Ironclaw"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
||||
openclaw agent --message "Ship checklist" --thinking high
|
||||
# Talk to the agent
|
||||
ironclaw agent --message "Summarize today's tasks" --thinking high
|
||||
```
|
||||
|
||||
Upgrading? [Updating guide](https://docs.openclaw.ai/install/updating) (and run `openclaw doctor`).
|
||||
## Web UI
|
||||
|
||||
## Development channels
|
||||
The web application lives in `apps/web/` and is built with Next.js. It provides:
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
|
||||
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
|
||||
- **Chat panel** with streaming responses, chain-of-thought display, and markdown rendering (via `react-markdown` + `remark-gfm`).
|
||||
- **Workspace sidebar** with a file manager tree, knowledge tree, and database viewer.
|
||||
- **Object tables** with sorting, filtering, row selection, and bulk delete.
|
||||
- **Entry detail modals** with field editing and media previews.
|
||||
- **Report cards** with chart panels and filter bars.
|
||||
- **Media viewer** supporting images, video, audio, and PDFs.
|
||||
|
||||
Switch channels (git + npm): `openclaw update --channel stable|beta|dev`.
|
||||
Details: [Development channels](https://docs.openclaw.ai/install/development-channels).
|
||||
|
||||
## From source (development)
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
To run the web UI in development:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kumarabhirup/openclaw-ai-sdk.git
|
||||
cd openclaw-ai-sdk
|
||||
|
||||
cd apps/web
|
||||
pnpm install
|
||||
pnpm ui:build # auto-installs UI deps on first run
|
||||
pnpm build
|
||||
|
||||
pnpm openclaw onboard --install-daemon
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary.
|
||||
|
||||
### Syncing with upstream OpenClaw
|
||||
|
||||
This fork is designed to minimize merge conflicts with upstream:
|
||||
|
||||
```bash
|
||||
# Add upstream remote (one-time)
|
||||
git remote add upstream https://github.com/openclaw/openclaw.git
|
||||
|
||||
# Pull latest upstream changes
|
||||
git fetch upstream
|
||||
git merge upstream/main
|
||||
|
||||
# AI SDK code lives in src/agents/aisdk/ - isolated from pi-agent changes
|
||||
```
|
||||
|
||||
## Security defaults (DM access)
|
||||
|
||||
OpenClaw connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
||||
|
||||
Full security guide: [Security](https://docs.openclaw.ai/gateway/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
|
||||
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `openclaw pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
||||
|
||||
Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.openclaw.ai/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://docs.openclaw.ai/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes).
|
||||
- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
### Core platform
|
||||
|
||||
- [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor).
|
||||
- **Dual LLM Engine** (this fork): AI SDK v6 (default) or Pi agent runtime, switchable via config.
|
||||
- **[Vercel AI SDK v6](https://ai-sdk.dev/)** integration: `streamText()`, provider-specific options (thinking, reasoning, effort), AI Gateway support.
|
||||
- [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming (fallback engine).
|
||||
- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/concepts/groups).
|
||||
- [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio).
|
||||
|
||||
### Channels
|
||||
|
||||
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
|
||||
- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
|
||||
|
||||
### Apps + nodes
|
||||
|
||||
- [macOS app](https://docs.openclaw.ai/platforms/macos): menu bar control plane, [Voice Wake](https://docs.openclaw.ai/nodes/voicewake)/PTT, [Talk Mode](https://docs.openclaw.ai/nodes/talk) overlay, [WebChat](https://docs.openclaw.ai/web/webchat), debug tools, [remote gateway](https://docs.openclaw.ai/gateway/remote) control.
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, optional SMS.
|
||||
- [macOS node mode](https://docs.openclaw.ai/nodes): system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
|
||||
- [Browser control](https://docs.openclaw.ai/tools/browser): dedicated openclaw Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.openclaw.ai/platforms/mac/canvas): [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.openclaw.ai/nodes): camera snap/clip, screen record, [location.get](https://docs.openclaw.ai/nodes/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.openclaw.ai/automation/cron-jobs); [webhooks](https://docs.openclaw.ai/automation/webhook); [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub).
|
||||
- [Skills platform](https://docs.openclaw.ai/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Runtime + safety
|
||||
|
||||
- [Channel routing](https://docs.openclaw.ai/concepts/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming).
|
||||
- [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking).
|
||||
- [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning).
|
||||
- [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting).
|
||||
|
||||
### Ops + packaging
|
||||
|
||||
- [Control UI](https://docs.openclaw.ai/web) + [WebChat](https://docs.openclaw.ai/web/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.openclaw.ai/gateway/tailscale) or [SSH tunnels](https://docs.openclaw.ai/gateway/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.openclaw.ai/install/nix) for declarative config; [Docker](https://docs.openclaw.ai/install/docker)-based installs.
|
||||
- [Doctor](https://docs.openclaw.ai/gateway/doctor) migrations, [logging](https://docs.openclaw.ai/logging).
|
||||
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │
|
||||
│ (control plane) │
|
||||
│ ws://127.0.0.1:18789 │
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Engine Router // AI SDK v6 & Pi agent
|
||||
├─ CLI (openclaw …)
|
||||
├─ WebChat UI
|
||||
├─ macOS app
|
||||
└─ iOS / Android nodes
|
||||
```
|
||||
|
||||
## Key subsystems
|
||||
|
||||
- **[Gateway WebSocket network](https://docs.openclaw.ai/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.openclaw.ai/gateway)).
|
||||
- **[Tailscale exposure](https://docs.openclaw.ai/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.openclaw.ai/gateway/remote)).
|
||||
- **[Browser control](https://docs.openclaw.ai/tools/browser)** — openclaw‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.openclaw.ai/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always‑on speech and continuous conversation.
|
||||
- **[Nodes](https://docs.openclaw.ai/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
|
||||
## Tailscale access (Gateway dashboard)
|
||||
|
||||
OpenClaw can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
|
||||
|
||||
- `off`: no Tailscale automation (default).
|
||||
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
|
||||
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
|
||||
|
||||
Notes:
|
||||
|
||||
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (OpenClaw enforces this).
|
||||
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
|
||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
||||
|
||||
Details: [Tailscale guide](https://docs.openclaw.ai/gateway/tailscale) · [Web surfaces](https://docs.openclaw.ai/web)
|
||||
|
||||
## Remote Gateway (Linux is great)
|
||||
|
||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||
|
||||
- **Gateway host** runs the exec tool and channel connections by default.
|
||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
||||
|
||||
Details: [Remote access](https://docs.openclaw.ai/gateway/remote) · [Nodes](https://docs.openclaw.ai/nodes) · [Security](https://docs.openclaw.ai/gateway/security)
|
||||
|
||||
## macOS permissions via the Gateway protocol
|
||||
|
||||
The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:
|
||||
|
||||
- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`).
|
||||
- `system.notify` posts a user notification and fails if notifications are denied.
|
||||
- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.
|
||||
|
||||
Elevated bash (host permissions) is separate from macOS TCC:
|
||||
|
||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||
|
||||
Details: [Nodes](https://docs.openclaw.ai/nodes) · [macOS app](https://docs.openclaw.ai/platforms/macos) · [Gateway protocol](https://docs.openclaw.ai/concepts/architecture)
|
||||
|
||||
## Agent to Agent (sessions\_\* tools)
|
||||
|
||||
- Use these to coordinate work across sessions without jumping between chat surfaces.
|
||||
- `sessions_list` — discover active sessions (agents) and their metadata.
|
||||
- `sessions_history` — fetch transcript logs for a session.
|
||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||
|
||||
Details: [Session tools](https://docs.openclaw.ai/concepts/session-tool)
|
||||
|
||||
## Skills registry (ClawHub)
|
||||
|
||||
ClawHub is a minimal skill registry. With ClawHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
|
||||
[ClawHub](https://clawhub.com)
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
- `/verbose on|off`
|
||||
- `/usage off|tokens|full` — per-response usage footer
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
## Apps (optional)
|
||||
|
||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||
|
||||
If you plan to build/run companion apps, follow the platform runbooks below.
|
||||
|
||||
### macOS (OpenClaw.app) (optional)
|
||||
|
||||
- Menu bar control for the Gateway and health.
|
||||
- Voice Wake + push-to-talk overlay.
|
||||
- WebChat + debug tools.
|
||||
- Remote gateway control over SSH.
|
||||
|
||||
Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
|
||||
|
||||
### iOS node (optional)
|
||||
|
||||
- Pairs as a node via the Bridge.
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `openclaw nodes …`.
|
||||
|
||||
Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
|
||||
|
||||
### Android node (optional)
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
- Workspace root: `~/.openclaw/workspace` (configurable via `agents.defaults.workspace`).
|
||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||
- Skills: `~/.openclaw/workspace/skills/<skill>/SKILL.md`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
||||
Ironclaw stores its config at `~/.openclaw/openclaw.json`. Minimal example:
|
||||
|
||||
```json5
|
||||
{
|
||||
@ -400,237 +93,117 @@ Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
||||
}
|
||||
```
|
||||
|
||||
[Full configuration reference (all keys + examples).](https://docs.openclaw.ai/gateway/configuration)
|
||||
### Supported providers
|
||||
|
||||
## Security model (important)
|
||||
| Provider | Environment Variable | Models |
|
||||
| ---------- | ------------------------------ | --------------------------------- |
|
||||
| Anthropic | `ANTHROPIC_API_KEY` | Claude 4/3.x, Opus, Sonnet, Haiku |
|
||||
| OpenAI | `OPENAI_API_KEY` | GPT-4o, GPT-4, o1, o3 |
|
||||
| Google | `GOOGLE_GENERATIVE_AI_API_KEY` | Gemini 2.x, 1.5 Pro |
|
||||
| OpenRouter | `OPENROUTER_API_KEY` | 100+ models |
|
||||
| Groq | `GROQ_API_KEY` | Llama, Mixtral |
|
||||
| Mistral | `MISTRAL_API_KEY` | Mistral models |
|
||||
| xAI | `XAI_API_KEY` | Grok models |
|
||||
| Azure | `AZURE_OPENAI_API_KEY` | Azure OpenAI models |
|
||||
|
||||
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
||||
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
### Thinking / reasoning
|
||||
|
||||
Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker + sandboxing](https://docs.openclaw.ai/install/docker) · [Sandbox config](https://docs.openclaw.ai/gateway/configuration)
|
||||
```bash
|
||||
# Set thinking level (maps to AI SDK budgetTokens)
|
||||
ironclaw agent --message "Complex analysis" --thinking high
|
||||
|
||||
### [WhatsApp](https://docs.openclaw.ai/channels/whatsapp)
|
||||
|
||||
- Link the device: `pnpm openclaw channels login` (stores creds in `~/.openclaw/credentials`).
|
||||
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
|
||||
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Telegram](https://docs.openclaw.ai/channels/telegram)
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
|
||||
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF",
|
||||
},
|
||||
},
|
||||
}
|
||||
# Levels: off, minimal, low, medium, high, xhigh
|
||||
```
|
||||
|
||||
### [Slack](https://docs.openclaw.ai/channels/slack)
|
||||
## Channel setup
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
|
||||
Each channel is configured in `~/.openclaw/openclaw.json` under `channels.*`:
|
||||
|
||||
### [Discord](https://docs.openclaw.ai/channels/discord)
|
||||
- **WhatsApp** -- Link via `ironclaw channels login`. Set `channels.whatsapp.allowFrom`.
|
||||
- **Telegram** -- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken`.
|
||||
- **Slack** -- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`.
|
||||
- **Discord** -- Set `DISCORD_BOT_TOKEN` or `channels.discord.token`.
|
||||
- **Signal** -- Requires `signal-cli` + `channels.signal` config.
|
||||
- **iMessage** -- Via BlueBubbles (recommended) or legacy macOS-only integration.
|
||||
- **Microsoft Teams** -- Configure a Teams app + Bot Framework.
|
||||
- **WebChat** -- Uses the Gateway WebSocket directly, no extra config.
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
## Chat commands
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
token: "1234abcd",
|
||||
},
|
||||
},
|
||||
}
|
||||
Send these in any connected channel:
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------- | ------------------------------- |
|
||||
| `/status` | Session status (model + tokens) |
|
||||
| `/new` or `/reset` | Reset the session |
|
||||
| `/compact` | Compact session context |
|
||||
| `/think <level>` | Set thinking level |
|
||||
| `/verbose on\|off` | Toggle verbose output |
|
||||
| `/usage off\|tokens\|full` | Per-response usage footer |
|
||||
| `/restart` | Restart the gateway |
|
||||
| `/activation mention\|always` | Group activation toggle |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Teams / WebChat
|
||||
|
|
||||
v
|
||||
+----------------------------+
|
||||
| Gateway |
|
||||
| (control plane) |
|
||||
| ws://127.0.0.1:18789 |
|
||||
+-------------+--------------+
|
||||
|
|
||||
+-- Vercel AI SDK v6 engine
|
||||
+-- CLI (ironclaw ...)
|
||||
+-- Web UI (Dench)
|
||||
+-- macOS app
|
||||
+-- iOS / Android nodes
|
||||
```
|
||||
|
||||
### [Signal](https://docs.openclaw.ai/channels/signal)
|
||||
## Project structure
|
||||
|
||||
- Requires `signal-cli` and a `channels.signal` config section.
|
||||
|
||||
### [BlueBubbles (iMessage)](https://docs.openclaw.ai/channels/bluebubbles)
|
||||
|
||||
- **Recommended** iMessage integration.
|
||||
- Configure `channels.bluebubbles.serverUrl` + `channels.bluebubbles.password` and a webhook (`channels.bluebubbles.webhookPath`).
|
||||
- The BlueBubbles server runs on macOS; the Gateway can run on macOS or elsewhere.
|
||||
|
||||
### [iMessage (legacy)](https://docs.openclaw.ai/channels/imessage)
|
||||
|
||||
- Legacy macOS-only integration via `imsg` (Messages must be signed in).
|
||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams)
|
||||
|
||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
||||
|
||||
### [WebChat](https://docs.openclaw.ai/web/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
|
||||
Browser control (optional):
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
color: "#FF4500",
|
||||
},
|
||||
}
|
||||
```
|
||||
src/ Core CLI, commands, gateway, agent, media pipeline
|
||||
apps/web/ Next.js web UI (Dench)
|
||||
apps/ios/ iOS companion node
|
||||
apps/android/ Android companion node
|
||||
apps/macos/ macOS menu bar app
|
||||
extensions/ Channel plugins (MS Teams, Matrix, Zalo, voice-call)
|
||||
docs/ Documentation
|
||||
scripts/ Build, deploy, and utility scripts
|
||||
skills/ Workspace skills
|
||||
```
|
||||
|
||||
## Docs
|
||||
## Development
|
||||
|
||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
```bash
|
||||
pnpm install # Install deps
|
||||
pnpm build # Type-check + build
|
||||
pnpm check # Lint + format check
|
||||
pnpm test # Run tests (vitest)
|
||||
pnpm test:coverage # Tests with coverage
|
||||
pnpm dev # Dev mode (auto-reload)
|
||||
```
|
||||
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.openclaw.ai)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.openclaw.ai/concepts/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.openclaw.ai/gateway/configuration)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.openclaw.ai/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.openclaw.ai/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.openclaw.ai/gateway/remote)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.openclaw.ai/start/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.openclaw.ai/automation/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.openclaw.ai/platforms/windows), [Linux](https://docs.openclaw.ai/platforms/linux), [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.openclaw.ai/channels/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.openclaw.ai/gateway/security)
|
||||
## Security
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
- DM pairing is enabled by default -- unknown senders receive a pairing code.
|
||||
- Approve senders with `ironclaw pairing approve <channel> <code>`.
|
||||
- Non-main sessions can be sandboxed in Docker (`agents.defaults.sandbox.mode: "non-main"`).
|
||||
- Run `ironclaw doctor` to surface risky or misconfigured DM policies.
|
||||
|
||||
- [Discovery + transports](https://docs.openclaw.ai/gateway/discovery)
|
||||
- [Bonjour/mDNS](https://docs.openclaw.ai/gateway/bonjour)
|
||||
- [Gateway pairing](https://docs.openclaw.ai/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.openclaw.ai/gateway/remote-gateway-readme)
|
||||
- [Control UI](https://docs.openclaw.ai/web/control-ui)
|
||||
- [Dashboard](https://docs.openclaw.ai/web/dashboard)
|
||||
## Upstream
|
||||
|
||||
## Operations & troubleshooting
|
||||
Ironclaw is a fork of [OpenClaw](https://github.com/openclaw/openclaw). To sync with upstream:
|
||||
|
||||
- [Health checks](https://docs.openclaw.ai/gateway/health)
|
||||
- [Gateway lock](https://docs.openclaw.ai/gateway/gateway-lock)
|
||||
- [Background process](https://docs.openclaw.ai/gateway/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.openclaw.ai/tools/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.openclaw.ai/logging)
|
||||
```bash
|
||||
git remote add upstream https://github.com/openclaw/openclaw.git
|
||||
git fetch upstream
|
||||
git merge upstream/main
|
||||
```
|
||||
|
||||
## Deep dives
|
||||
## License
|
||||
|
||||
- [Agent loop](https://docs.openclaw.ai/concepts/agent-loop)
|
||||
- [Presence](https://docs.openclaw.ai/concepts/presence)
|
||||
- [TypeBox schemas](https://docs.openclaw.ai/concepts/typebox)
|
||||
- [RPC adapters](https://docs.openclaw.ai/reference/rpc)
|
||||
- [Queue](https://docs.openclaw.ai/concepts/queue)
|
||||
|
||||
## Workspace & skills
|
||||
|
||||
- [Skills config](https://docs.openclaw.ai/tools/skills-config)
|
||||
- [Default AGENTS](https://docs.openclaw.ai/reference/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.openclaw.ai/reference/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.openclaw.ai/reference/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.openclaw.ai/reference/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.openclaw.ai/reference/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.openclaw.ai/reference/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.openclaw.ai/reference/templates/USER)
|
||||
|
||||
## Platform internals
|
||||
|
||||
- [macOS dev setup](https://docs.openclaw.ai/platforms/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.openclaw.ai/platforms/mac/voicewake)
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios)
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android)
|
||||
- [Windows (WSL2)](https://docs.openclaw.ai/platforms/windows)
|
||||
- [Linux app](https://docs.openclaw.ai/platforms/linux)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
- [docs.openclaw.ai/gmail-pubsub](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
|
||||
## About This Fork
|
||||
|
||||
This AI SDK fork was created by [Kumar Abhirup](https://github.com/kumarabhirup) to bring Vercel AI SDK compatibility to OpenClaw, making it easier for developers in the Vercel/Next.js ecosystem to integrate and extend.
|
||||
|
||||
**Fork features:**
|
||||
|
||||
- Vercel AI SDK v6 as the default LLM orchestration layer
|
||||
- Full Anthropic thinking/reasoning support via provider options
|
||||
- AI Gateway support for unified provider access
|
||||
- Fork-friendly architecture that minimizes upstream merge conflicts
|
||||
- Dual engine support (AI SDK + pi-agent) for flexibility
|
||||
|
||||
## Molty
|
||||
|
||||
OpenClaw was built for **Molty**, a space lobster AI assistant. 🦞
|
||||
by Peter Steinberger and the community.
|
||||
|
||||
- [openclaw.ai](https://openclaw.ai)
|
||||
- [soul.md](https://soul.md)
|
||||
- [steipete.me](https://steipete.me)
|
||||
- [@openclaw](https://x.com/openclaw)
|
||||
|
||||
## Community
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
[pi-mono](https://github.com/badlogic/pi-mono).
|
||||
Special thanks to Adam Doppelt for lobster.bot.
|
||||
Special thanks to [Vercel](https://vercel.com) for the [AI SDK](https://ai-sdk.dev/).
|
||||
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a>
|
||||
<a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a>
|
||||
<a href="https://github.com/abdelsfane"><img src="https://avatars.githubusercontent.com/u/32418586?v=4&s=48" width="48" height="48" alt="abdelsfane" title="abdelsfane"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/ethanpalm"><img src="https://avatars.githubusercontent.com/u/56270045?v=4&s=48" width="48" height="48" alt="ethanpalm" title="ethanpalm"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a>
|
||||
<a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/mudrii"><img src="https://avatars.githubusercontent.com/u/220262?v=4&s=48" width="48" height="48" alt="mudrii" title="mudrii"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
|
||||
<a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/BunsDev"><img src="https://avatars.githubusercontent.com/u/68980965?v=4&s=48" width="48" height="48" alt="BunsDev" title="BunsDev"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a>
|
||||
<a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/coygeek"><img src="https://avatars.githubusercontent.com/u/65363919?v=4&s=48" width="48" height="48" alt="coygeek" title="coygeek"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/M00N7682"><img src="https://avatars.githubusercontent.com/u/170746674?v=4&s=48" width="48" height="48" alt="M00N7682" title="M00N7682"/></a>
|
||||
<a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="aerolalit" title="aerolalit"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/lsh411"><img src="https://avatars.githubusercontent.com/u/6801488?v=4&s=48" width="48" height="48" alt="lsh411" title="lsh411"/></a>
|
||||
<a href="https://github.com/gut-puncture"><img src="https://avatars.githubusercontent.com/u/75851986?v=4&s=48" width="48" height="48" alt="gut-puncture" title="gut-puncture"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/elliotsecops"><img src="https://avatars.githubusercontent.com/u/141947839?v=4&s=48" width="48" height="48" alt="elliotsecops" title="elliotsecops"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
|
||||
<a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/pycckuu"><img src="https://avatars.githubusercontent.com/u/1489583?v=4&s=48" width="48" height="48" alt="pycckuu" title="pycckuu"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a>
|
||||
<a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></a> <a href="https://github.com/TinyTb"><img src="https://avatars.githubusercontent.com/u/5957298?v=4&s=48" width="48" height="48" alt="TinyTb" title="TinyTb"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/nicolasstanley"><img src="https://avatars.githubusercontent.com/u/60584925?v=4&s=48" width="48" height="48" alt="nicolasstanley" title="nicolasstanley"/></a> <a href="https://github.com/davidiach"><img src="https://avatars.githubusercontent.com/u/28102235?v=4&s=48" width="48" height="48" alt="davidiach" title="davidiach"/></a> <a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggialiang" title="nonggialiang"/></a>
|
||||
<a href="https://github.com/ironbyte-rgb"><img src="https://avatars.githubusercontent.com/u/230665944?v=4&s=48" width="48" height="48" alt="ironbyte-rgb" title="ironbyte-rgb"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/Iranb"><img src="https://avatars.githubusercontent.com/u/49674669?v=4&s=48" width="48" height="48" alt="Iranb" title="Iranb"/></a>
|
||||
<a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="papago2355" title="papago2355"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/evanotero"><img src="https://avatars.githubusercontent.com/u/13204105?v=4&s=48" width="48" height="48" alt="evanotero" title="evanotero"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="jlowin" title="jlowin"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a>
|
||||
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryancontent" title="ryancontent"/></a> <a href="https://github.com/jasonsschin"><img src="https://avatars.githubusercontent.com/u/1456889?v=4&s=48" width="48" height="48" alt="jasonsschin" title="jasonsschin"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a>
|
||||
<a href="https://github.com/HirokiKobayashi-R"><img src="https://avatars.githubusercontent.com/u/37167840?v=4&s=48" width="48" height="48" alt="HirokiKobayashi-R" title="HirokiKobayashi-R"/></a> <a href="https://github.com/ThanhNguyxn"><img src="https://avatars.githubusercontent.com/u/74597207?v=4&s=48" width="48" height="48" alt="ThanhNguyxn" title="ThanhNguyxn"/></a> <a href="https://github.com/18-RAJAT"><img src="https://avatars.githubusercontent.com/u/78920780?v=4&s=48" width="48" height="48" alt="18-RAJAT" title="18-RAJAT"/></a> <a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="kimitaka" title="kimitaka"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="yuting0624" title="yuting0624"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/unisone"><img src="https://avatars.githubusercontent.com/u/32521398?v=4&s=48" width="48" height="48" alt="unisone" title="unisone"/></a> <a href="https://github.com/baccula"><img src="https://avatars.githubusercontent.com/u/22080883?v=4&s=48" width="48" height="48" alt="baccula" title="baccula"/></a>
|
||||
<a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="manikv12" title="manikv12"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/fujiwara-tofu-shop"><img src="https://avatars.githubusercontent.com/u/259415332?v=4&s=48" width="48" height="48" alt="fujiwara-tofu-shop" title="fujiwara-tofu-shop"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/slonce70"><img src="https://avatars.githubusercontent.com/u/130596182?v=4&s=48" width="48" height="48" alt="slonce70" title="slonce70"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
|
||||
<a href="https://github.com/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
|
||||
<a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="shivamraut101" title="shivamraut101"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Wangnov"><img src="https://avatars.githubusercontent.com/u/48670012?v=4&s=48" width="48" height="48" alt="Wangnov" title="Wangnov"/></a> <a href="https://github.com/kaizen403"><img src="https://avatars.githubusercontent.com/u/134706404?v=4&s=48" width="48" height="48" alt="kaizen403" title="kaizen403"/></a>
|
||||
<a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/therealZpoint-bot"><img src="https://avatars.githubusercontent.com/u/258706705?v=4&s=48" width="48" height="48" alt="therealZpoint-bot" title="therealZpoint-bot"/></a> <a href="https://github.com/wangai-studio"><img src="https://avatars.githubusercontent.com/u/256938352?v=4&s=48" width="48" height="48" alt="wangai-studio" title="wangai-studio"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="kennyklee" title="kennyklee"/></a>
|
||||
<a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/Hisleren"><img src="https://avatars.githubusercontent.com/u/83217244?v=4&s=48" width="48" height="48" alt="Hisleren" title="Hisleren"/></a> <a href="https://github.com/shatner"><img src="https://avatars.githubusercontent.com/u/17735435?v=4&s=48" width="48" height="48" alt="shatner" title="shatner"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="GHesericsu" title="GHesericsu"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
|
||||
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/robhparker"><img src="https://avatars.githubusercontent.com/u/7404740?v=4&s=48" width="48" height="48" alt="robhparker" title="robhparker"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
|
||||
<a href="https://github.com/Yeom-JinHo"><img src="https://avatars.githubusercontent.com/u/81306489?v=4&s=48" width="48" height="48" alt="Yeom-JinHo" title="Yeom-JinHo"/></a> <a href="https://github.com/doodlewind"><img src="https://avatars.githubusercontent.com/u/7312949?v=4&s=48" width="48" height="48" alt="doodlewind" title="doodlewind"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/hyf0-agent"><img src="https://avatars.githubusercontent.com/u/258783736?v=4&s=48" width="48" height="48" alt="hyf0-agent" title="hyf0-agent"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
|
||||
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/abhijeet117"><img src="https://avatars.githubusercontent.com/u/192859219?v=4&s=48" width="48" height="48" alt="abhijeet117" title="abhijeet117"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a>
|
||||
<a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="itsjling" title="itsjling"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></a> <a href="https://github.com/kelvinCB"><img src="https://avatars.githubusercontent.com/u/50544379?v=4&s=48" width="48" height="48" alt="kelvinCB" title="kelvinCB"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/mattqdev"><img src="https://avatars.githubusercontent.com/u/115874885?v=4&s=48" width="48" height="48" alt="mattqdev" title="mattqdev"/></a> <a href="https://github.com/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></a>
|
||||
<a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/spiceoogway"><img src="https://avatars.githubusercontent.com/u/105812383?v=4&s=48" width="48" height="48" alt="spiceoogway" title="spiceoogway"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
|
||||
<a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/bravostation"><img src="https://avatars.githubusercontent.com/u/257991910?v=4&s=48" width="48" height="48" alt="bravostation" title="bravostation"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/j2h4u"><img src="https://avatars.githubusercontent.com/u/39818683?v=4&s=48" width="48" height="48" alt="j2h4u" title="j2h4u"/></a>
|
||||
<a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/search?q=Roopak%20Nijhara"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Roopak Nijhara" title="Roopak Nijhara"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/search?q=xiaose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="xiaose" title="xiaose"/></a>
|
||||
<a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/aldoeliacim"><img src="https://avatars.githubusercontent.com/u/17973757?v=4&s=48" width="48" height="48" alt="aldoeliacim" title="aldoeliacim"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/BinaryMuse"><img src="https://avatars.githubusercontent.com/u/189606?v=4&s=48" width="48" height="48" alt="BinaryMuse" title="BinaryMuse"/></a> <a href="https://github.com/bqcfjwhz85-arch"><img src="https://avatars.githubusercontent.com/u/239267175?v=4&s=48" width="48" height="48" alt="bqcfjwhz85-arch" title="bqcfjwhz85-arch"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a>
|
||||
<a href="https://github.com/search?q=damaozi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="damaozi" title="damaozi"/></a> <a href="https://github.com/danballance"><img src="https://avatars.githubusercontent.com/u/13839912?v=4&s=48" width="48" height="48" alt="danballance" title="danballance"/></a> <a href="https://github.com/Elarwei001"><img src="https://avatars.githubusercontent.com/u/168552401?v=4&s=48" width="48" height="48" alt="Elarwei001" title="Elarwei001"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gildo"><img src="https://avatars.githubusercontent.com/u/133645?v=4&s=48" width="48" height="48" alt="gildo" title="gildo"/></a> <a href="https://github.com/hclsys"><img src="https://avatars.githubusercontent.com/u/7755017?v=4&s=48" width="48" height="48" alt="hclsys" title="hclsys"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a>
|
||||
<a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo" title="lailoo"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Marco%20Marandiz"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marco Marandiz" title="Marco Marandiz"/></a>
|
||||
<a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/mattezell"><img src="https://avatars.githubusercontent.com/u/361409?v=4&s=48" width="48" height="48" alt="mattezell" title="mattezell"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
|
||||
<a href="https://github.com/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/tewatia"><img src="https://avatars.githubusercontent.com/u/22875334?v=4&s=48" width="48" height="48" alt="tewatia" title="tewatia"/></a> <a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/yudshj"><img src="https://avatars.githubusercontent.com/u/16971372?v=4&s=48" width="48" height="48" alt="yudshj" title="yudshj"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a>
|
||||
<a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a> <a href="https://github.com/aisling404"><img src="https://avatars.githubusercontent.com/u/211950534?v=4&s=48" width="48" height="48" alt="aisling404" title="aisling404"/></a> <a href="https://github.com/akramcodez"><img src="https://avatars.githubusercontent.com/u/179671552?v=4&s=48" width="48" height="48" alt="akramcodez" title="akramcodez"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/AlexZhangji"><img src="https://avatars.githubusercontent.com/u/3280924?v=4&s=48" width="48" height="48" alt="AlexZhangji" title="AlexZhangji"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
|
||||
<a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/search?q=Ayush%20Ojha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ayush Ojha" title="Ayush Ojha"/></a> <a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/caelum0x"><img src="https://avatars.githubusercontent.com/u/130079063?v=4&s=48" width="48" height="48" alt="caelum0x" title="caelum0x"/></a> <a href="https://github.com/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a>
|
||||
<a href="https://github.com/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></a> <a href="https://github.com/Chloe-VP"><img src="https://avatars.githubusercontent.com/u/257371598?v=4&s=48" width="48" height="48" alt="Chloe-VP" title="Chloe-VP"/></a> <a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/deepsoumya617"><img src="https://avatars.githubusercontent.com/u/80877391?v=4&s=48" width="48" height="48" alt="deepsoumya617" title="deepsoumya617"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a>
|
||||
<a href="https://github.com/dvrshil"><img src="https://avatars.githubusercontent.com/u/81693876?v=4&s=48" width="48" height="48" alt="dvrshil" title="dvrshil"/></a> <a href="https://github.com/dxd5001"><img src="https://avatars.githubusercontent.com/u/1886046?v=4&s=48" width="48" height="48" alt="dxd5001" title="dxd5001"/></a> <a href="https://github.com/dylanneve1"><img src="https://avatars.githubusercontent.com/u/31746704?v=4&s=48" width="48" height="48" alt="dylanneve1" title="dylanneve1"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a> <a href="https://github.com/fredheir"><img src="https://avatars.githubusercontent.com/u/3304869?v=4&s=48" width="48" height="48" alt="fredheir" title="fredheir"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a>
|
||||
<a href="https://github.com/HassanFleyah"><img src="https://avatars.githubusercontent.com/u/228002017?v=4&s=48" width="48" height="48" alt="HassanFleyah" title="HassanFleyah"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/iamEvanYT"><img src="https://avatars.githubusercontent.com/u/47493765?v=4&s=48" width="48" height="48" alt="iamEvanYT" title="iamEvanYT"/></a> <a href="https://github.com/ichbinlucaskim"><img src="https://avatars.githubusercontent.com/u/125564751?v=4&s=48" width="48" height="48" alt="ichbinlucaskim" title="ichbinlucaskim"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></a> <a href="https://github.com/search?q=Jarvis%20Deploy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis Deploy" title="Jarvis Deploy"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
|
||||
<a href="https://github.com/jogi47"><img src="https://avatars.githubusercontent.com/u/1710139?v=4&s=48" width="48" height="48" alt="jogi47" title="jogi47"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kira-ariaki"><img src="https://avatars.githubusercontent.com/u/257352493?v=4&s=48" width="48" height="48" alt="kira-ariaki" title="kira-ariaki"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loganaden"><img src="https://avatars.githubusercontent.com/u/1688420?v=4&s=48" width="48" height="48" alt="loganaden" title="loganaden"/></a> <a href="https://github.com/longjos"><img src="https://avatars.githubusercontent.com/u/740160?v=4&s=48" width="48" height="48" alt="longjos" title="longjos"/></a>
|
||||
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></a> <a href="https://github.com/search?q=mac%20mimi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mac mimi" title="mac mimi"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mcaxtr"><img src="https://avatars.githubusercontent.com/u/7562095?v=4&s=48" width="48" height="48" alt="mcaxtr" title="mcaxtr"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
|
||||
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/mylukin"><img src="https://avatars.githubusercontent.com/u/1021019?v=4&s=48" width="48" height="48" alt="mylukin" title="mylukin"/></a> <a href="https://github.com/nathanbosse"><img src="https://avatars.githubusercontent.com/u/4040669?v=4&s=48" width="48" height="48" alt="nathanbosse" title="nathanbosse"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/Omar-Khaleel"><img src="https://avatars.githubusercontent.com/u/240748662?v=4&s=48" width="48" height="48" alt="Omar-Khaleel" title="Omar-Khaleel"/></a> <a href="https://github.com/ozgur-polat"><img src="https://avatars.githubusercontent.com/u/26483942?v=4&s=48" width="48" height="48" alt="ozgur-polat" title="ozgur-polat"/></a> <a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
|
||||
<a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/rafelbev"><img src="https://avatars.githubusercontent.com/u/467120?v=4&s=48" width="48" height="48" alt="rafelbev" title="rafelbev"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/ryancnelson"><img src="https://avatars.githubusercontent.com/u/347171?v=4&s=48" width="48" height="48" alt="ryancnelson" title="ryancnelson"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a>
|
||||
<a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></a> <a href="https://github.com/Shrinija17"><img src="https://avatars.githubusercontent.com/u/199155426?v=4&s=48" width="48" height="48" alt="Shrinija17" title="Shrinija17"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/stephenchen2025"><img src="https://avatars.githubusercontent.com/u/218387130?v=4&s=48" width="48" height="48" alt="stephenchen2025" title="stephenchen2025"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
|
||||
<a href="https://github.com/search?q=Vibe%20Kanban"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vibe Kanban" title="Vibe Kanban"/></a> <a href="https://github.com/vincentkoc"><img src="https://avatars.githubusercontent.com/u/25068?v=4&s=48" width="48" height="48" alt="vincentkoc" title="vincentkoc"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/wytheme"><img src="https://avatars.githubusercontent.com/u/5009358?v=4&s=48" width="48" height="48" alt="wytheme" title="wytheme"/></a> <a href="https://github.com/YangHuang2280"><img src="https://avatars.githubusercontent.com/u/201681634?v=4&s=48" width="48" height="48" alt="YangHuang2280" title="YangHuang2280"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a>
|
||||
<a href="https://github.com/yevhen"><img src="https://avatars.githubusercontent.com/u/107726?v=4&s=48" width="48" height="48" alt="yevhen" title="yevhen"/></a> <a href="https://github.com/YiWang24"><img src="https://avatars.githubusercontent.com/u/176262341?v=4&s=48" width="48" height="48" alt="YiWang24" title="YiWang24"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/zackerthescar"><img src="https://avatars.githubusercontent.com/u/38077284?v=4&s=48" width="48" height="48" alt="zackerthescar" title="zackerthescar"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
[MIT](LICENSE)
|
||||
|
||||
26
SECURITY.md
26
SECURITY.md
@ -4,9 +4,31 @@ If you believe you've found a security issue in OpenClaw, please report it priva
|
||||
|
||||
## Reporting
|
||||
|
||||
For full reporting instructions - including which repo to report to and how - see our [Trust page](https://trust.openclaw.ai).
|
||||
Report vulnerabilities directly to the repository where the issue lives:
|
||||
|
||||
Include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
|
||||
- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw)
|
||||
- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos)
|
||||
- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios)
|
||||
- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android)
|
||||
- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub)
|
||||
- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust)
|
||||
|
||||
For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it.
|
||||
|
||||
For full reporting instructions see our [Trust page](https://trust.openclaw.ai).
|
||||
|
||||
### Required in Reports
|
||||
|
||||
1. **Title**
|
||||
2. **Severity Assessment**
|
||||
3. **Impact**
|
||||
4. **Affected Component**
|
||||
5. **Technical Reproduction**
|
||||
6. **Demonstrated Impact**
|
||||
7. **Environment**
|
||||
8. **Remediation Advice**
|
||||
|
||||
Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues.
|
||||
|
||||
## Security & Trust
|
||||
|
||||
|
||||
124
appcast.xml
124
appcast.xml
@ -2,6 +2,62 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.2.9</title>
|
||||
<pubDate>Mon, 09 Feb 2026 13:23:25 -0600</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>9194</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.9</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.9</h2>
|
||||
<h3>Added</h3>
|
||||
<ul>
|
||||
<li>iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.</li>
|
||||
<li>Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.</li>
|
||||
<li>Plugins: device pairing + phone control plugins (Telegram <code>/pair</code>, iOS/Android node controls). (#11755) Thanks @mbelinky.</li>
|
||||
<li>Tools: add Grok (xAI) as a <code>web_search</code> provider. (#12419) Thanks @tmchow.</li>
|
||||
<li>Gateway: add agent management RPC methods for the web UI (<code>agents.create</code>, <code>agents.update</code>, <code>agents.delete</code>). (#11045) Thanks @advaitpaliwal.</li>
|
||||
<li>Web UI: show a Compaction divider in chat history. (#11341) Thanks @Takhoffman.</li>
|
||||
<li>Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman.</li>
|
||||
<li>Paths: add <code>OPENCLAW_HOME</code> for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.</li>
|
||||
<li>Telegram: recover proactive sends when stale topic thread IDs are used by retrying without <code>message_thread_id</code>. (#11620)</li>
|
||||
<li>Telegram: render markdown spoilers with <code><tg-spoiler></code> HTML tags. (#11543) Thanks @ezhikkk.</li>
|
||||
<li>Telegram: truncate command registration to 100 entries to avoid <code>BOT_COMMANDS_TOO_MUCH</code> failures on startup. (#12356) Thanks @arosstale.</li>
|
||||
<li>Telegram: match DM <code>allowFrom</code> against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual).</li>
|
||||
<li>Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials.</li>
|
||||
<li>Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.</li>
|
||||
<li>Tools/web_search: include provider-specific settings in the web search cache key, and pass <code>inlineCitations</code> for Grok. (#12419) Thanks @tmchow.</li>
|
||||
<li>Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.</li>
|
||||
<li>Model failover: treat HTTP 400 errors as failover-eligible, enabling automatic model fallback. (#1879) Thanks @orenyomtov.</li>
|
||||
<li>Errors: prevent false positive context overflow detection when conversation mentions "context overflow" topic. (#2078) Thanks @sbking.</li>
|
||||
<li>Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session <code>parentId</code> chain so agents can remember again. (#12283) Thanks @Takhoffman.</li>
|
||||
<li>Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman.</li>
|
||||
<li>Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.</li>
|
||||
<li>Subagents/compaction: stabilize announce timing and preserve compaction metrics across retries. (#11664) Thanks @tyler6204.</li>
|
||||
<li>Cron: share isolated announce flow and harden scheduling/delivery reliability. (#11641) Thanks @tyler6204.</li>
|
||||
<li>Cron tool: recover flat params when LLM omits the <code>job</code> wrapper for add requests. (#12124) Thanks @tyler6204.</li>
|
||||
<li>Gateway/CLI: when <code>gateway.bind=lan</code>, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.</li>
|
||||
<li>Hooks: fix bundled hooks broken since 2026.2.2 (tsdown migration). (#9295) Thanks @patrickshao.</li>
|
||||
<li>Routing: refresh bindings per message by loading config at route resolution so binding changes apply without restart. (#11372) Thanks @juanpablodlc.</li>
|
||||
<li>Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight.</li>
|
||||
<li>Config: clamp <code>maxTokens</code> to <code>contextWindow</code> to prevent invalid model configs. (#5516) Thanks @lailoo.</li>
|
||||
<li>Thinking: allow xhigh for <code>github-copilot/gpt-5.2-codex</code> and <code>github-copilot/gpt-5.2</code>. (#11646) Thanks @LatencyTDH.</li>
|
||||
<li>Discord: support forum/media thread-create starter messages, wire <code>message thread create --message</code>, and harden routing. (#10062) Thanks @jarvis89757.</li>
|
||||
<li>Paths: structurally resolve <code>OPENCLAW_HOME</code>-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.</li>
|
||||
<li>Memory: set Voyage embeddings <code>input_type</code> for improved retrieval. (#10818) Thanks @mcinteerj.</li>
|
||||
<li>Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204.</li>
|
||||
<li>Media understanding: recognize <code>.caf</code> audio attachments for transcription. (#10982) Thanks @succ985.</li>
|
||||
<li>State dir: honor <code>OPENCLAW_STATE_DIR</code> for default device identity and canvas storage paths. (#4824) Thanks @kossoy.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.9/OpenClaw-2026.2.9.zip" length="22872529" type="application/octet-stream" sparkle:edSignature="zvgwqlgqI7J5Gsi9VSULIQTMKqLiGE5ulC6NnRLKtOPphQsHZVdYSWm0E90+Yq8mG4lpsvbxQOSSPxpl43QTAw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.3</title>
|
||||
<pubDate>Wed, 04 Feb 2026 17:47:10 -0800</pubDate>
|
||||
@ -96,71 +152,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.2/OpenClaw-2026.2.2.zip" length="22519052" type="application/octet-stream" sparkle:edSignature="a6viD+aS5EfY/RkPIPMfoQQNkJCk6QTdV5WobXFxyYwURskUm8/nXTHVXsCh1c5+0WKUnmlDIyf0i+6IWiavAA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.1</title>
|
||||
<pubDate>Mon, 02 Feb 2026 03:53:03 -0800</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>8650</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.1</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Docs: onboarding/install/i18n/exec-approvals/Control UI/exe.dev/cacheRetention updates + misc nav/typos. (#3050, #3461, #4064, #4675, #4729, #4763, #5003, #5402, #5446, #5474, #5663, #5689, #5694, #5967, #6270, #6300, #6311, #6416, #6487, #6550, #6789)</li>
|
||||
<li>Telegram: use shared pairing store. (#6127) Thanks @obviyus.</li>
|
||||
<li>Agents: add OpenRouter app attribution headers. Thanks @alexanderatallah.</li>
|
||||
<li>Agents: add system prompt safety guardrails. (#5445) Thanks @joshp123.</li>
|
||||
<li>Agents: update pi-ai to 0.50.9 and rename cacheControlTtl -> cacheRetention (with back-compat mapping).</li>
|
||||
<li>Agents: extend CreateAgentSessionOptions with systemPrompt/skills/contextFiles.</li>
|
||||
<li>Agents: add tool policy conformance snapshot (no runtime behavior change). (#6011)</li>
|
||||
<li>Auth: update MiniMax OAuth hint + portal auth note copy.</li>
|
||||
<li>Discord: inherit thread parent bindings for routing. (#3892) Thanks @aerolalit.</li>
|
||||
<li>Gateway: inject timestamps into agent and chat.send messages. (#3705) Thanks @conroywhitney, @CashWilliams.</li>
|
||||
<li>Gateway: require TLS 1.3 minimum for TLS listeners. (#5970) Thanks @loganaden.</li>
|
||||
<li>Web UI: refine chat layout + extend session active duration.</li>
|
||||
<li>CI: add formal conformance + alias consistency checks. (#5723, #5807)</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Plugins: validate plugin/hook install paths and reject traversal-like names.</li>
|
||||
<li>Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.</li>
|
||||
<li>Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus.</li>
|
||||
<li>Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014)</li>
|
||||
<li>Streaming: stabilize partial streaming filters.</li>
|
||||
<li>Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation.</li>
|
||||
<li>Tools: align tool execute adapters/signatures (legacy + parameter order + arg normalization).</li>
|
||||
<li>Tools: treat <code>"*"</code> tool allowlist entries as valid to avoid spurious unknown-entry warnings.</li>
|
||||
<li>Skills: update session-logs paths from .clawdbot to .openclaw. (#4502)</li>
|
||||
<li>Slack: harden media fetch limits and Slack file URL validation. (#6639) Thanks @davidiach.</li>
|
||||
<li>Lint: satisfy curly rule after import sorting. (#6310)</li>
|
||||
<li>Process: resolve Windows <code>spawn()</code> failures for npm-family CLIs by appending <code>.cmd</code> when needed. (#5815) Thanks @thejhinvirtuoso.</li>
|
||||
<li>Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow.</li>
|
||||
<li>Tlon: add timeout to SSE client fetch calls (CWE-400). (#5926)</li>
|
||||
<li>Memory search: L2-normalize local embedding vectors to fix semantic search. (#5332)</li>
|
||||
<li>Agents: align embedded runner + typings with pi-coding-agent API updates (pi 0.51.0).</li>
|
||||
<li>Agents: ensure OpenRouter attribution headers apply in the embedded runner.</li>
|
||||
<li>Agents: cap context window resolution for compaction safeguard. (#6187) Thanks @iamEvanYT.</li>
|
||||
<li>System prompt: resolve overrides and hint using session_status for current date/time. (#1897, #1928, #2108, #3677)</li>
|
||||
<li>Agents: fix Pi prompt template argument syntax. (#6543)</li>
|
||||
<li>Subagents: fix announce failover race (always emit lifecycle end; timeout=0 means no-timeout). (#6621)</li>
|
||||
<li>Teams: gate media auth retries.</li>
|
||||
<li>Telegram: restore draft streaming partials. (#5543) Thanks @obviyus.</li>
|
||||
<li>Onboarding: friendlier Windows onboarding message. (#6242) Thanks @shanselman.</li>
|
||||
<li>TUI: prevent crash when searching with digits in the model selector.</li>
|
||||
<li>Agents: wire before_tool_call plugin hook into tool execution. (#6570, #6660) Thanks @ryancnelson.</li>
|
||||
<li>Browser: secure Chrome extension relay CDP sessions.</li>
|
||||
<li>Docker: use container port for gateway command instead of host port. (#5110) Thanks @mise42.</li>
|
||||
<li>fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07.</li>
|
||||
<li>Security: sanitize WhatsApp accountId to prevent path traversal. (#4610)</li>
|
||||
<li>Security: restrict MEDIA path extraction to prevent LFI. (#4930)</li>
|
||||
<li>Security: validate message-tool filePath/path against sandbox root. (#6398)</li>
|
||||
<li>Security: block LD*/DYLD* env overrides for host exec. (#4896) Thanks @HassanFleyah.</li>
|
||||
<li>Security: harden web tool content wrapping + file parsing safeguards. (#4058) Thanks @VACInc.</li>
|
||||
<li>Security: enforce Twitch <code>allowFrom</code> allowlist gating (deny non-allowlisted senders). Thanks @MegaManSec.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.1/OpenClaw-2026.2.1.zip" length="22458919" type="application/octet-stream" sparkle:edSignature="kA/8VQlVdtYphcB1iuFrhWczwWKgkVZMfDfQ7T9WD405D8JKTv5CZ1n8lstIVkpk4xog3UhrfaaoTG8Bf8DMAQ=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
@ -22,7 +22,7 @@ android {
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602030
|
||||
versionName = "2026.2.6"
|
||||
versionName = "2026.2.10"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@ -17,13 +17,13 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.6</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260202</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.10</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260202</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@ -15,10 +15,10 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.6</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260202</string>
|
||||
</dict>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.10</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260202</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -81,7 +81,7 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.2.6"
|
||||
CFBundleShortVersionString: "2026.2.10"
|
||||
CFBundleVersion: "20260202"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.2.6"
|
||||
CFBundleShortVersionString: "2026.2.10"
|
||||
CFBundleVersion: "20260202"
|
||||
|
||||
@ -585,34 +585,38 @@ extension MenuSessionsInjector {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.isEnabled = false
|
||||
let view = AnyView(SessionMenuPreviewView(
|
||||
width: width,
|
||||
maxLines: maxLines,
|
||||
title: title,
|
||||
items: [],
|
||||
status: .loading))
|
||||
let hosting = NSHostingView(rootView: view)
|
||||
hosting.frame.size.width = max(1, width)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
item.view = hosting
|
||||
let view = AnyView(
|
||||
SessionMenuPreviewView(
|
||||
width: width,
|
||||
maxLines: maxLines,
|
||||
title: title,
|
||||
items: [],
|
||||
status: .loading)
|
||||
.environment(\.isEnabled, true))
|
||||
let hosted = HighlightedMenuItemHostView(rootView: view, width: width)
|
||||
item.view = hosted
|
||||
|
||||
let task = Task { [weak hosting] in
|
||||
let task = Task { [weak hosted, weak item] in
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10)
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run {
|
||||
guard let hosting else { return }
|
||||
let nextView = AnyView(SessionMenuPreviewView(
|
||||
width: width,
|
||||
maxLines: maxLines,
|
||||
title: title,
|
||||
items: snapshot.items,
|
||||
status: snapshot.status))
|
||||
hosting.rootView = nextView
|
||||
hosting.invalidateIntrinsicContentSize()
|
||||
hosting.frame.size.width = max(1, width)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame.size.height = size.height
|
||||
let nextView = AnyView(
|
||||
SessionMenuPreviewView(
|
||||
width: width,
|
||||
maxLines: maxLines,
|
||||
title: title,
|
||||
items: snapshot.items,
|
||||
status: snapshot.status)
|
||||
.environment(\.isEnabled, true))
|
||||
|
||||
if let item {
|
||||
item.view = HighlightedMenuItemHostView(rootView: nextView, width: width)
|
||||
return
|
||||
}
|
||||
|
||||
guard let hosted else { return }
|
||||
hosted.update(rootView: nextView, width: width)
|
||||
}
|
||||
}
|
||||
self.previewTasks.append(task)
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.6</string>
|
||||
<string>2026.2.10</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202602020</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
|
||||
@ -250,7 +250,8 @@ actor GatewayWizardClient {
|
||||
let clientId = "openclaw-macos"
|
||||
let clientMode = "ui"
|
||||
let role = "operator"
|
||||
let scopes: [String] = []
|
||||
// Explicit scopes; gateway no longer defaults empty scopes to admin.
|
||||
let scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"]
|
||||
let client: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(clientId),
|
||||
"displayName": ProtoAnyCodable(Host.current().localizedName ?? "OpenClaw macOS Wizard CLI"),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
// Generated by scripts/protocol-gen-swift.ts — do not edit by hand
|
||||
// swiftlint:disable file_length
|
||||
import Foundation
|
||||
|
||||
public let GATEWAY_PROTOCOL_VERSION = 3
|
||||
@ -383,7 +384,7 @@ public struct AgentEvent: Codable, Sendable {
|
||||
|
||||
public struct SendParams: Codable, Sendable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let message: String?
|
||||
public let mediaurl: String?
|
||||
public let mediaurls: [String]?
|
||||
public let gifplayback: Bool?
|
||||
@ -394,7 +395,7 @@ public struct SendParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
to: String,
|
||||
message: String,
|
||||
message: String?,
|
||||
mediaurl: String?,
|
||||
mediaurls: [String]?,
|
||||
gifplayback: Bool?,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
// Generated by scripts/protocol-gen-swift.ts — do not edit by hand
|
||||
// swiftlint:disable file_length
|
||||
import Foundation
|
||||
|
||||
public let GATEWAY_PROTOCOL_VERSION = 3
|
||||
@ -383,7 +384,7 @@ public struct AgentEvent: Codable, Sendable {
|
||||
|
||||
public struct SendParams: Codable, Sendable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let message: String?
|
||||
public let mediaurl: String?
|
||||
public let mediaurls: [String]?
|
||||
public let gifplayback: Bool?
|
||||
@ -394,7 +395,7 @@ public struct SendParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
to: String,
|
||||
message: String,
|
||||
message: String?,
|
||||
mediaurl: String?,
|
||||
mediaurls: [String]?,
|
||||
gifplayback: Bool?,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { runAgent, type ToolResult } from "@/lib/agent-runner";
|
||||
import { resolveAgentWorkspacePrefix } from "@/lib/workspace";
|
||||
|
||||
// Force Node.js runtime (required for child_process)
|
||||
export const runtime = "nodejs";
|
||||
@ -14,9 +15,9 @@ export const maxDuration = 600;
|
||||
function buildToolOutput(
|
||||
result?: ToolResult,
|
||||
): Record<string, unknown> {
|
||||
if (!result) return {};
|
||||
if (!result) {return {};}
|
||||
const out: Record<string, unknown> = {};
|
||||
if (result.text) out.text = result.text;
|
||||
if (result.text) {out.text = result.text;}
|
||||
if (result.details) {
|
||||
// Forward useful details (exit code, duration, status, cwd)
|
||||
for (const key of [
|
||||
@ -28,14 +29,15 @@ function buildToolOutput(
|
||||
"reason",
|
||||
]) {
|
||||
if (result.details[key] !== undefined)
|
||||
out[key] = result.details[key];
|
||||
{out[key] = result.details[key];}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages }: { messages: UIMessage[] } = await req.json();
|
||||
const { messages, sessionId }: { messages: UIMessage[]; sessionId?: string } =
|
||||
await req.json();
|
||||
|
||||
// Extract the latest user message text
|
||||
const lastUserMessage = messages.filter((m) => m.role === "user").pop();
|
||||
@ -51,6 +53,18 @@ export async function POST(req: Request) {
|
||||
return new Response("No message provided", { status: 400 });
|
||||
}
|
||||
|
||||
// Resolve workspace file paths to be agent-cwd-relative.
|
||||
// Tree paths are workspace-root-relative (e.g. "knowledge/leads/foo.md"),
|
||||
// but the agent runs from the repo root and needs "dench/knowledge/leads/foo.md".
|
||||
let agentMessage = userText;
|
||||
const wsPrefix = resolveAgentWorkspacePrefix();
|
||||
if (wsPrefix) {
|
||||
agentMessage = userText.replace(
|
||||
/\[Context: workspace file '([^']+)'\]/,
|
||||
`[Context: workspace file '${wsPrefix}/$1']`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create a custom SSE stream using the AI SDK v6 data stream wire format.
|
||||
// DefaultChatTransport parses these events into UIMessage parts automatically.
|
||||
const encoder = new TextEncoder();
|
||||
@ -72,10 +86,13 @@ export async function POST(req: Request) {
|
||||
// onLifecycleEnd closes the text part (textStarted→false), so
|
||||
// onClose can't rely on textStarted alone to detect "no output".
|
||||
let everSentText = false;
|
||||
// Track whether the status reasoning block is the one currently open
|
||||
// so we can close it cleanly when real content arrives.
|
||||
let statusReasoningActive = false;
|
||||
|
||||
/** Write an SSE event; silently no-ops if the stream was already cancelled. */
|
||||
const writeEvent = (data: unknown) => {
|
||||
if (closed) return;
|
||||
if (closed) {return;}
|
||||
const json = JSON.stringify(data);
|
||||
controller.enqueue(encoder.encode(`data: ${json}\n\n`));
|
||||
};
|
||||
@ -88,6 +105,7 @@ export async function POST(req: Request) {
|
||||
id: currentReasoningId,
|
||||
});
|
||||
reasoningStarted = false;
|
||||
statusReasoningActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
@ -99,9 +117,38 @@ export async function POST(req: Request) {
|
||||
}
|
||||
};
|
||||
|
||||
/** Open a status reasoning block (auto-closes any existing one). */
|
||||
const openStatusReasoning = (label: string) => {
|
||||
closeReasoning();
|
||||
closeText();
|
||||
currentReasoningId = nextId("status");
|
||||
writeEvent({
|
||||
type: "reasoning-start",
|
||||
id: currentReasoningId,
|
||||
});
|
||||
writeEvent({
|
||||
type: "reasoning-delta",
|
||||
id: currentReasoningId,
|
||||
delta: label,
|
||||
});
|
||||
reasoningStarted = true;
|
||||
statusReasoningActive = true;
|
||||
};
|
||||
|
||||
try {
|
||||
await runAgent(userText, abortController.signal, {
|
||||
await runAgent(agentMessage, abortController.signal, {
|
||||
onLifecycleStart: () => {
|
||||
// Show immediate feedback — the agent has started working.
|
||||
// This eliminates the "Streaming... (silence)" gap.
|
||||
openStatusReasoning("Preparing response...");
|
||||
},
|
||||
|
||||
onThinkingDelta: (delta) => {
|
||||
// Close the status block if it's still the active one;
|
||||
// real reasoning content is now arriving.
|
||||
if (statusReasoningActive) {
|
||||
closeReasoning();
|
||||
}
|
||||
if (!reasoningStarted) {
|
||||
currentReasoningId = nextId("reasoning");
|
||||
writeEvent({
|
||||
@ -185,26 +232,75 @@ export async function POST(req: Request) {
|
||||
}
|
||||
},
|
||||
|
||||
onCompactionStart: () => {
|
||||
// Show compaction status while the gateway is
|
||||
// optimizing the session context (can take 10-30s).
|
||||
openStatusReasoning("Optimizing session context...");
|
||||
},
|
||||
|
||||
onCompactionEnd: (willRetry) => {
|
||||
// Close the compaction status block. If the gateway
|
||||
// will retry the prompt, leave the reasoning area open
|
||||
// so the next status/thinking block follows smoothly.
|
||||
if (statusReasoningActive) {
|
||||
if (willRetry) {
|
||||
// Append a note, keep block open for retry
|
||||
writeEvent({
|
||||
type: "reasoning-delta",
|
||||
id: currentReasoningId,
|
||||
delta: "\nRetrying with compacted context...",
|
||||
});
|
||||
} else {
|
||||
closeReasoning();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onLifecycleEnd: () => {
|
||||
closeReasoning();
|
||||
closeText();
|
||||
},
|
||||
|
||||
onError: (err) => {
|
||||
console.error("[chat] Agent error:", err);
|
||||
onAgentError: (message) => {
|
||||
// Surface agent-level errors (API 402, rate limits, etc.)
|
||||
// as visible text in the chat so the user sees what happened.
|
||||
closeReasoning();
|
||||
if (!textStarted) {
|
||||
currentTextId = nextId("text");
|
||||
writeEvent({
|
||||
type: "text-start",
|
||||
id: currentTextId,
|
||||
});
|
||||
textStarted = true;
|
||||
}
|
||||
closeText();
|
||||
|
||||
currentTextId = nextId("text");
|
||||
writeEvent({
|
||||
type: "text-start",
|
||||
id: currentTextId,
|
||||
});
|
||||
writeEvent({
|
||||
type: "text-delta",
|
||||
id: currentTextId,
|
||||
delta: `Error starting agent: ${err.message}`,
|
||||
delta: `[error] ${message}`,
|
||||
});
|
||||
writeEvent({
|
||||
type: "text-end",
|
||||
id: currentTextId,
|
||||
});
|
||||
textStarted = false;
|
||||
everSentText = true;
|
||||
},
|
||||
|
||||
onError: (err) => {
|
||||
console.error("[chat] Agent error:", err);
|
||||
closeReasoning();
|
||||
closeText();
|
||||
|
||||
currentTextId = nextId("text");
|
||||
writeEvent({
|
||||
type: "text-start",
|
||||
id: currentTextId,
|
||||
});
|
||||
textStarted = true;
|
||||
everSentText = true;
|
||||
writeEvent({
|
||||
type: "text-delta",
|
||||
id: currentTextId,
|
||||
delta: `[error] Failed to start agent: ${err.message}`,
|
||||
});
|
||||
writeEvent({ type: "text-end", id: currentTextId });
|
||||
textStarted = false;
|
||||
@ -219,10 +315,14 @@ export async function POST(req: Request) {
|
||||
type: "text-start",
|
||||
id: currentTextId,
|
||||
});
|
||||
const msg =
|
||||
_code !== null && _code !== 0
|
||||
? `[error] Agent exited with code ${_code}. Check server logs for details.`
|
||||
: "[error] No response from agent.";
|
||||
writeEvent({
|
||||
type: "text-delta",
|
||||
id: currentTextId,
|
||||
delta: "(No response from agent)",
|
||||
delta: msg,
|
||||
});
|
||||
writeEvent({
|
||||
type: "text-end",
|
||||
@ -233,7 +333,7 @@ export async function POST(req: Request) {
|
||||
closeText();
|
||||
}
|
||||
},
|
||||
});
|
||||
}, sessionId ? { sessionId } : undefined);
|
||||
} catch (error) {
|
||||
console.error("[chat] Stream error:", error);
|
||||
writeEvent({
|
||||
|
||||
@ -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();
|
||||
|
||||
57
apps/web/app/api/workspace/assets/[...path]/route.ts
Normal file
57
apps/web/app/api/workspace/assets/[...path]/route.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { extname } from "node:path";
|
||||
import { safeResolvePath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".bmp": "image/bmp",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/workspace/assets/<path>
|
||||
* Serves an image file from the workspace's assets/ directory.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const segments = (await params).path;
|
||||
if (!segments || segments.length === 0) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const relPath = "assets/" + segments.join("/");
|
||||
const ext = extname(relPath).toLowerCase();
|
||||
|
||||
// Only serve known image types
|
||||
const mime = MIME_MAP[ext];
|
||||
if (!mime) {
|
||||
return new Response("Unsupported file type", { status: 400 });
|
||||
}
|
||||
|
||||
const absPath = safeResolvePath(relPath);
|
||||
if (!absPath || !existsSync(absPath)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = readFileSync(absPath);
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": mime,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response("Read error", { status: 500 });
|
||||
}
|
||||
}
|
||||
116
apps/web/app/api/workspace/context/route.ts
Normal file
116
apps/web/app/api/workspace/context/route.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveDenchRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export type WorkspaceContext = {
|
||||
exists: boolean;
|
||||
organization?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
};
|
||||
members?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}>;
|
||||
defaults?: {
|
||||
default_view?: string;
|
||||
date_format?: string;
|
||||
naming_convention?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse workspace_context.yaml with basic YAML extraction.
|
||||
* Handles the specific structure defined by the Dench skill.
|
||||
*/
|
||||
function parseWorkspaceContext(content: string): WorkspaceContext {
|
||||
const ctx: WorkspaceContext = { exists: true };
|
||||
|
||||
// Extract organization block
|
||||
const orgMatch = content.match(
|
||||
/organization:\s*\n((?:\s{2,}.+\n)*)/,
|
||||
);
|
||||
if (orgMatch) {
|
||||
const orgBlock = orgMatch[1];
|
||||
const org: Record<string, string> = {};
|
||||
for (const line of orgBlock.split("\n")) {
|
||||
const kv = line.match(/^\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
|
||||
if (kv) {org[kv[1]] = kv[2].trim();}
|
||||
}
|
||||
ctx.organization = {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract members list
|
||||
const membersMatch = content.match(
|
||||
/members:\s*\n((?:\s{2,}.+\n)*)/,
|
||||
);
|
||||
if (membersMatch) {
|
||||
const membersBlock = membersMatch[1];
|
||||
const members: WorkspaceContext["members"] = [];
|
||||
let current: Record<string, string> = {};
|
||||
|
||||
for (const line of membersBlock.split("\n")) {
|
||||
const itemStart = line.match(/^\s+-\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
|
||||
const propLine = line.match(/^\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
|
||||
|
||||
if (itemStart) {
|
||||
if (current.id) {members.push(current as never);}
|
||||
current = { [itemStart[1]]: itemStart[2].trim() };
|
||||
} else if (propLine && !line.trim().startsWith("-")) {
|
||||
current[propLine[1]] = propLine[2].trim();
|
||||
}
|
||||
}
|
||||
if (current.id) {members.push(current as never);}
|
||||
ctx.members = members;
|
||||
}
|
||||
|
||||
// Extract defaults block
|
||||
const defaultsMatch = content.match(
|
||||
/defaults:\s*\n((?:\s{2,}.+\n)*)/,
|
||||
);
|
||||
if (defaultsMatch) {
|
||||
const defaultsBlock = defaultsMatch[1];
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const line of defaultsBlock.split("\n")) {
|
||||
const kv = line.match(/^\s+(\w[\w_]*)\s*:\s*(.+)/);
|
||||
if (kv) {defaults[kv[1]] = kv[2].trim();}
|
||||
}
|
||||
ctx.defaults = {
|
||||
default_view: defaults.default_view,
|
||||
date_format: defaults.date_format,
|
||||
naming_convention: defaults.naming_convention,
|
||||
};
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const root = resolveDenchRoot();
|
||||
if (!root) {
|
||||
return Response.json({ exists: false } satisfies WorkspaceContext);
|
||||
}
|
||||
|
||||
const ctxPath = join(root, "workspace_context.yaml");
|
||||
if (!existsSync(ctxPath)) {
|
||||
return Response.json({ exists: true } satisfies WorkspaceContext);
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(ctxPath, "utf-8");
|
||||
const parsed = parseWorkspaceContext(content);
|
||||
return Response.json(parsed);
|
||||
} catch {
|
||||
return Response.json({ exists: true } satisfies WorkspaceContext);
|
||||
}
|
||||
}
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
93
apps/web/app/api/workspace/db/introspect/route.ts
Normal file
93
apps/web/app/api/workspace/db/introspect/route.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { safeResolvePath, duckdbQueryOnFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type TableInfo = {
|
||||
table_name: string;
|
||||
column_count: number;
|
||||
estimated_row_count: number;
|
||||
columns: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
is_nullable: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/workspace/db/introspect?path=<relative-path>
|
||||
*
|
||||
* Introspects a DuckDB / SQLite / generic DB file using the duckdb CLI.
|
||||
* Returns the list of tables with their columns and approximate row counts.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const relPath = searchParams.get("path");
|
||||
|
||||
if (!relPath) {
|
||||
return Response.json(
|
||||
{ error: "Missing required `path` query parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = safeResolvePath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "File not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get all user tables (skip internal DuckDB catalogs)
|
||||
const rawTables = duckdbQueryOnFile<{
|
||||
table_name: string;
|
||||
table_type: string;
|
||||
}>(
|
||||
absPath,
|
||||
"SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name",
|
||||
);
|
||||
|
||||
if (rawTables.length === 0) {
|
||||
return Response.json({ tables: [], path: relPath });
|
||||
}
|
||||
|
||||
const tables: TableInfo[] = [];
|
||||
|
||||
for (const t of rawTables) {
|
||||
// Fetch columns for this table
|
||||
const cols = duckdbQueryOnFile<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}>(
|
||||
absPath,
|
||||
`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = 'main' AND table_name = '${t.table_name.replace(/'/g, "''")}' ORDER BY ordinal_position`,
|
||||
);
|
||||
|
||||
// Get approximate row count
|
||||
let rowCount = 0;
|
||||
try {
|
||||
const countResult = duckdbQueryOnFile<{ cnt: number }>(
|
||||
absPath,
|
||||
`SELECT count(*) as cnt FROM "${t.table_name.replace(/"/g, '""')}"`,
|
||||
);
|
||||
rowCount = countResult[0]?.cnt ?? 0;
|
||||
} catch {
|
||||
// skip if we can't count
|
||||
}
|
||||
|
||||
tables.push({
|
||||
table_name: t.table_name,
|
||||
column_count: cols.length,
|
||||
estimated_row_count: rowCount,
|
||||
columns: cols.map((c) => ({
|
||||
name: c.column_name,
|
||||
type: c.data_type,
|
||||
is_nullable: c.is_nullable === "YES",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({ tables, path: relPath });
|
||||
}
|
||||
56
apps/web/app/api/workspace/db/query/route.ts
Normal file
56
apps/web/app/api/workspace/db/query/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { safeResolvePath, duckdbQueryOnFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/workspace/db/query
|
||||
* Body: { path: string, sql: string }
|
||||
*
|
||||
* Executes a read-only SQL query against a database file and returns JSON rows.
|
||||
* Only SELECT statements are allowed for safety.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
let body: { path?: string; sql?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: relPath, sql } = body;
|
||||
|
||||
if (!relPath || !sql) {
|
||||
return Response.json(
|
||||
{ error: "Missing required `path` and `sql` fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Basic safety: only allow SELECT-like statements
|
||||
const trimmedSql = sql.trim().toUpperCase();
|
||||
if (
|
||||
!trimmedSql.startsWith("SELECT") &&
|
||||
!trimmedSql.startsWith("PRAGMA") &&
|
||||
!trimmedSql.startsWith("DESCRIBE") &&
|
||||
!trimmedSql.startsWith("SHOW") &&
|
||||
!trimmedSql.startsWith("EXPLAIN") &&
|
||||
!trimmedSql.startsWith("WITH")
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "Only read-only queries (SELECT, DESCRIBE, SHOW, EXPLAIN, WITH) are allowed" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = safeResolvePath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "File not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = duckdbQueryOnFile(absPath, sql);
|
||||
return Response.json({ rows, sql });
|
||||
}
|
||||
121
apps/web/app/api/workspace/file/route.ts
Normal file
121
apps/web/app/api/workspace/file/route.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { writeFileSync, mkdirSync, rmSync, statSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { readWorkspaceFile, safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.searchParams.get("path");
|
||||
|
||||
if (!path) {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' query parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const file = readWorkspaceFile(path);
|
||||
if (!file) {
|
||||
return Response.json(
|
||||
{ error: "File not found or access denied" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/workspace/file
|
||||
* Body: { path: string, content: string }
|
||||
*
|
||||
* Writes a file to the dench workspace. Creates parent directories as needed.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
let body: { path?: string; content?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: relPath, content } = body;
|
||||
if (!relPath || typeof relPath !== "string" || typeof content !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' and 'content' fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 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" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(absPath), { recursive: true });
|
||||
writeFileSync(absPath, content, "utf-8");
|
||||
return Response.json({ ok: true, path: relPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Write failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
511
apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts
Normal file
511
apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts
Normal file
@ -0,0 +1,511 @@
|
||||
import {
|
||||
duckdbQuery,
|
||||
duckdbExec,
|
||||
duckdbPath,
|
||||
parseRelationValue,
|
||||
} from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type ObjectRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
default_view?: string;
|
||||
display_field?: string;
|
||||
};
|
||||
|
||||
type FieldRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
enum_values?: string;
|
||||
enum_colors?: string;
|
||||
enum_multiple?: boolean;
|
||||
related_object_id?: string;
|
||||
relationship_type?: string;
|
||||
sort_order?: number;
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
function tryParseJson(value: unknown): unknown {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDisplayField(
|
||||
obj: ObjectRow,
|
||||
fields: FieldRow[],
|
||||
): string {
|
||||
if (obj.display_field) {
|
||||
return obj.display_field;
|
||||
}
|
||||
const nameField = fields.find(
|
||||
(f) =>
|
||||
/\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
|
||||
);
|
||||
if (nameField) {
|
||||
return nameField.name;
|
||||
}
|
||||
const textField = fields.find((f) => f.type === "text");
|
||||
if (textField) {
|
||||
return textField.name;
|
||||
}
|
||||
return fields[0]?.name ?? "id";
|
||||
}
|
||||
|
||||
// --- Route handlers ---
|
||||
|
||||
/**
|
||||
* GET /api/workspace/objects/[name]/entries/[id]
|
||||
* Returns a single entry with all field values, relation labels, and reverse relations.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ name: string; id: string }> },
|
||||
) {
|
||||
const { name, id } = await params;
|
||||
|
||||
if (!duckdbPath()) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!id || id.length > 64) {
|
||||
return Response.json(
|
||||
{ error: "Invalid entry ID" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch object
|
||||
const objects = duckdbQuery<ObjectRow>(
|
||||
`SELECT * FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const obj = objects[0];
|
||||
|
||||
// Fetch fields
|
||||
const fields = duckdbQuery<FieldRow>(
|
||||
`SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`,
|
||||
);
|
||||
|
||||
// Fetch entry field values
|
||||
const entryRows = duckdbQuery<{
|
||||
entry_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
field_name: string;
|
||||
value: string | null;
|
||||
}>(
|
||||
`SELECT e.id as entry_id, e.created_at, e.updated_at,
|
||||
f.name as field_name, 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 = '${sqlEscape(id)}'
|
||||
AND e.object_id = '${sqlEscape(obj.id)}'`,
|
||||
);
|
||||
|
||||
if (entryRows.length === 0) {
|
||||
const exists = duckdbQuery<{ cnt: number }>(
|
||||
`SELECT COUNT(*) as cnt FROM entries WHERE id = '${sqlEscape(id)}' AND object_id = '${sqlEscape(obj.id)}'`,
|
||||
);
|
||||
if (!exists[0] || exists[0].cnt === 0) {
|
||||
return Response.json(
|
||||
{ error: "Entry not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pivot into a single record
|
||||
const entry: Record<string, unknown> = { entry_id: id };
|
||||
for (const row of entryRows) {
|
||||
entry.created_at ??= row.created_at;
|
||||
entry.updated_at ??= row.updated_at;
|
||||
if (row.field_name) {
|
||||
entry[row.field_name] = row.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse enum JSON strings in fields
|
||||
const parsedFields = fields.map((f) => ({
|
||||
...f,
|
||||
enum_values: f.enum_values
|
||||
? tryParseJson(f.enum_values)
|
||||
: undefined,
|
||||
enum_colors: f.enum_colors
|
||||
? tryParseJson(f.enum_colors)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
// Resolve relation labels for this entry
|
||||
const relationLabels: 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;
|
||||
|
||||
const val = entry[rf.name];
|
||||
if (val == null || val === "") {
|
||||
relationLabels[rf.name] = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
const ids = parseRelationValue(String(val));
|
||||
if (ids.length === 0) {
|
||||
relationLabels[rf.name] = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
const relFields = duckdbQuery<FieldRow>(
|
||||
`SELECT * FROM fields WHERE object_id = '${sqlEscape(relObj.id)}' ORDER BY sort_order`,
|
||||
);
|
||||
const displayFieldName = resolveDisplayField(
|
||||
relObj,
|
||||
relFields,
|
||||
);
|
||||
|
||||
const idList = ids
|
||||
.map((i) => `'${sqlEscape(i)}'`)
|
||||
.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;
|
||||
}
|
||||
for (const i of ids) {
|
||||
if (!labelMap[i]) {
|
||||
labelMap[i] = i;
|
||||
}
|
||||
}
|
||||
relationLabels[rf.name] = labelMap;
|
||||
}
|
||||
|
||||
// Enrich fields with related object names
|
||||
const enrichedFields = parsedFields.map((f) => ({
|
||||
...f,
|
||||
related_object_name:
|
||||
f.type === "relation"
|
||||
? relatedObjectNames[f.name]
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
// Find reverse relations for this entry
|
||||
const reverseRelations = findReverseRelationsForEntry(
|
||||
obj.id,
|
||||
id,
|
||||
);
|
||||
|
||||
const effectiveDisplayField = resolveDisplayField(obj, fields);
|
||||
|
||||
return Response.json({
|
||||
object: obj,
|
||||
fields: enrichedFields,
|
||||
entry,
|
||||
relationLabels,
|
||||
reverseRelations,
|
||||
effectiveDisplayField,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/workspace/objects/[name]/entries/[id]
|
||||
* Update field values for an entry.
|
||||
* Body: { fields: { [fieldName]: newValue } }
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ name: string; id: string }> },
|
||||
) {
|
||||
const { name, id } = await params;
|
||||
|
||||
if (!duckdbPath()) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find object
|
||||
const objects = duckdbQuery<{ id: string }>(
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
// Verify entry exists
|
||||
const exists = duckdbQuery<{ cnt: number }>(
|
||||
`SELECT COUNT(*) as cnt FROM entries WHERE id = '${sqlEscape(id)}' AND object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
if (!exists[0] || exists[0].cnt === 0) {
|
||||
return Response.json(
|
||||
{ error: "Entry not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const fieldUpdates: Record<string, string> =
|
||||
body.fields ?? {};
|
||||
|
||||
// Get field IDs by name
|
||||
const dbFields = duckdbQuery<{ id: string; name: string }>(
|
||||
`SELECT id, name FROM fields WHERE object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
const fieldMap = new Map(dbFields.map((f) => [f.name, f.id]));
|
||||
|
||||
let updatedCount = 0;
|
||||
for (const [fieldName, value] of Object.entries(fieldUpdates)) {
|
||||
const fieldId = fieldMap.get(fieldName);
|
||||
if (!fieldId) {continue;}
|
||||
|
||||
const escapedValue =
|
||||
value == null ? "NULL" : `'${sqlEscape(String(value))}'`;
|
||||
|
||||
// Try update first, then insert if no rows affected
|
||||
const existingRows = duckdbQuery<{ cnt: number }>(
|
||||
`SELECT COUNT(*) as cnt FROM entry_fields WHERE entry_id = '${sqlEscape(id)}' AND field_id = '${sqlEscape(fieldId)}'`,
|
||||
);
|
||||
|
||||
if (existingRows[0]?.cnt > 0) {
|
||||
duckdbExec(
|
||||
`UPDATE entry_fields SET value = ${escapedValue} WHERE entry_id = '${sqlEscape(id)}' AND field_id = '${sqlEscape(fieldId)}'`,
|
||||
);
|
||||
} else {
|
||||
duckdbExec(
|
||||
`INSERT INTO entry_fields (entry_id, field_id, value) VALUES ('${sqlEscape(id)}', '${sqlEscape(fieldId)}', ${escapedValue})`,
|
||||
);
|
||||
}
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
// Touch updated_at on the entry
|
||||
const now = new Date().toISOString();
|
||||
duckdbExec(
|
||||
`UPDATE entries SET updated_at = '${now}' WHERE id = '${sqlEscape(id)}'`,
|
||||
);
|
||||
|
||||
return Response.json({ ok: true, updatedCount });
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/workspace/objects/[name]/entries/[id]
|
||||
* Delete a single entry and its field values.
|
||||
*/
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ name: string; id: string }> },
|
||||
) {
|
||||
const { name, id } = await params;
|
||||
|
||||
if (!duckdbPath()) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find object
|
||||
const objects = duckdbQuery<{ id: string }>(
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
// Delete field values first, then entry
|
||||
duckdbExec(
|
||||
`DELETE FROM entry_fields WHERE entry_id = '${sqlEscape(id)}'`,
|
||||
);
|
||||
duckdbExec(
|
||||
`DELETE FROM entries WHERE id = '${sqlEscape(id)}' AND object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
// --- Reverse relations for a single entry ---
|
||||
|
||||
type ReverseRelation = {
|
||||
fieldName: string;
|
||||
sourceObjectName: string;
|
||||
sourceObjectId: string;
|
||||
displayField: string;
|
||||
links: Array<{ id: string; label: string }>;
|
||||
};
|
||||
|
||||
function findReverseRelationsForEntry(
|
||||
objectId: string,
|
||||
entryId: string,
|
||||
): ReverseRelation[] {
|
||||
const reverseFields = duckdbQuery<{
|
||||
id: string;
|
||||
name: string;
|
||||
object_id: string;
|
||||
source_object_name: string;
|
||||
}>(
|
||||
`SELECT f.id, f.name, f.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) {
|
||||
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 != ''`,
|
||||
);
|
||||
|
||||
const matchingSourceIds: string[] = [];
|
||||
for (const row of refRows) {
|
||||
const targetIds = parseRelationValue(row.target_value);
|
||||
if (targetIds.includes(entryId)) {
|
||||
matchingSourceIds.push(row.source_entry_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingSourceIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceObj = duckdbQuery<ObjectRow>(
|
||||
`SELECT * FROM objects WHERE id = '${sqlEscape(rrf.object_id)}' LIMIT 1`,
|
||||
);
|
||||
if (sourceObj.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceFields = duckdbQuery<FieldRow>(
|
||||
`SELECT * FROM fields WHERE object_id = '${sqlEscape(rrf.object_id)}' ORDER BY sort_order`,
|
||||
);
|
||||
const displayFieldName = resolveDisplayField(
|
||||
sourceObj[0],
|
||||
sourceFields,
|
||||
);
|
||||
|
||||
const idList = matchingSourceIds
|
||||
.map((i) => `'${sqlEscape(i)}'`)
|
||||
.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.object_id)}'`,
|
||||
);
|
||||
|
||||
const displayMap: Record<string, string> = {};
|
||||
for (const row of displayRows) {
|
||||
displayMap[row.entry_id] = row.value || row.entry_id;
|
||||
}
|
||||
|
||||
const links = matchingSourceIds.map((sid) => ({
|
||||
id: sid,
|
||||
label: displayMap[sid] || sid,
|
||||
}));
|
||||
|
||||
result.push({
|
||||
fieldName: rrf.name,
|
||||
sourceObjectName: rrf.source_object_name,
|
||||
sourceObjectId: rrf.object_id,
|
||||
displayField: displayFieldName,
|
||||
links,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
import { duckdbExec, duckdbQuery, duckdbPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/workspace/objects/[name]/entries/bulk-delete
|
||||
* Delete multiple entries at once.
|
||||
* Body: { entryIds: string[] }
|
||||
*/
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
|
||||
if (!duckdbPath()) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB 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 entryIds: string[] = body.entryIds;
|
||||
|
||||
if (!Array.isArray(entryIds) || entryIds.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "entryIds must be a non-empty array" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate object exists
|
||||
const objects = duckdbQuery<{ id: string }>(
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
const idList = entryIds
|
||||
.map((id) => `'${sqlEscape(id)}'`)
|
||||
.join(",");
|
||||
|
||||
// Delete field values first, then entries
|
||||
duckdbExec(
|
||||
`DELETE FROM entry_fields WHERE entry_id IN (${idList})`,
|
||||
);
|
||||
duckdbExec(
|
||||
`DELETE FROM entries WHERE id IN (${idList}) AND object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
deletedCount: entryIds.length,
|
||||
});
|
||||
}
|
||||
95
apps/web/app/api/workspace/objects/[name]/entries/route.ts
Normal file
95
apps/web/app/api/workspace/objects/[name]/entries/route.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { duckdbExec, duckdbQuery, duckdbPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/workspace/objects/[name]/entries
|
||||
* Create a new entry with optional field values.
|
||||
* Body: { fields?: Record<string, string> }
|
||||
*/
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
|
||||
if (!duckdbPath()) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find object
|
||||
const objects = duckdbQuery<{ id: string }>(
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
// Generate UUID for the new entry
|
||||
const idRows = duckdbQuery<{ id: string }>(
|
||||
"SELECT uuid()::VARCHAR as id",
|
||||
);
|
||||
const entryId = idRows[0]?.id;
|
||||
if (!entryId) {
|
||||
return Response.json(
|
||||
{ error: "Failed to generate UUID" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create entry
|
||||
const now = new Date().toISOString();
|
||||
const ok = duckdbExec(
|
||||
`INSERT INTO entries (id, object_id, created_at, updated_at) VALUES ('${sqlEscape(entryId)}', '${sqlEscape(objectId)}', '${now}', '${now}')`,
|
||||
);
|
||||
if (!ok) {
|
||||
return Response.json(
|
||||
{ error: "Failed to create entry" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Insert field values if provided
|
||||
let body: { fields?: Record<string, string> } = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
// no body is fine
|
||||
}
|
||||
|
||||
if (body.fields && typeof body.fields === "object") {
|
||||
// Get field IDs by name
|
||||
const dbFields = duckdbQuery<{ id: string; name: string }>(
|
||||
`SELECT id, name FROM fields WHERE object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
const fieldMap = new Map(dbFields.map((f) => [f.name, f.id]));
|
||||
|
||||
for (const [fieldName, value] of Object.entries(body.fields)) {
|
||||
const fieldId = fieldMap.get(fieldName);
|
||||
if (!fieldId || value == null) {continue;}
|
||||
duckdbExec(
|
||||
`INSERT INTO entry_fields (entry_id, field_id, value) VALUES ('${sqlEscape(entryId)}', '${sqlEscape(fieldId)}', '${sqlEscape(String(value))}')`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ entryId, ok: true }, { status: 201 });
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
import { duckdbExec, duckdbQuery, duckdbPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/workspace/objects/[name]/fields/[fieldId]
|
||||
* Rename a field.
|
||||
* Body: { name: string }
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{
|
||||
params,
|
||||
}: { params: Promise<{ name: string; fieldId: string }> },
|
||||
) {
|
||||
const { name, fieldId } = await params;
|
||||
|
||||
if (!duckdbPath()) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB 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 newName: string = body.name;
|
||||
|
||||
if (
|
||||
!newName ||
|
||||
typeof newName !== "string" ||
|
||||
newName.trim().length === 0
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "Name is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate object exists
|
||||
const objects = duckdbQuery<{ id: string }>(
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
// Validate field exists and belongs to this object
|
||||
const fieldExists = duckdbQuery<{ cnt: number }>(
|
||||
`SELECT COUNT(*) as cnt FROM fields WHERE id = '${sqlEscape(fieldId)}' AND object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
if (!fieldExists[0] || fieldExists[0].cnt === 0) {
|
||||
return Response.json(
|
||||
{ error: "Field not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
const duplicateCheck = duckdbQuery<{ cnt: number }>(
|
||||
`SELECT COUNT(*) as cnt FROM fields WHERE object_id = '${sqlEscape(objectId)}' AND name = '${sqlEscape(newName.trim())}' AND id != '${sqlEscape(fieldId)}'`,
|
||||
);
|
||||
if (duplicateCheck[0]?.cnt > 0) {
|
||||
return Response.json(
|
||||
{ error: "A field with that name already exists" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const ok = duckdbExec(
|
||||
`UPDATE fields SET name = '${sqlEscape(newName.trim())}' WHERE id = '${sqlEscape(fieldId)}'`,
|
||||
);
|
||||
|
||||
if (!ok) {
|
||||
return Response.json(
|
||||
{ error: "Failed to rename field" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import { duckdbExec, duckdbQuery, duckdbPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/workspace/objects/[name]/fields/reorder
|
||||
* Reorder fields by updating sort_order.
|
||||
* Body: { fieldOrder: string[] } — array of field IDs in desired order
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
|
||||
if (!duckdbPath()) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB 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 fieldOrder: string[] = body.fieldOrder;
|
||||
|
||||
if (!Array.isArray(fieldOrder) || fieldOrder.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "fieldOrder must be a non-empty array" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate object exists
|
||||
const objects = duckdbQuery<{ id: string }>(
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
// Update sort_order for each field
|
||||
for (let i = 0; i < fieldOrder.length; i++) {
|
||||
duckdbExec(
|
||||
`UPDATE fields SET sort_order = ${i} WHERE id = '${sqlEscape(fieldOrder[i])}' AND object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
417
apps/web/app/api/workspace/objects/[name]/route.ts
Normal file
417
apps/web/app/api/workspace/objects/[name]/route.ts
Normal file
@ -0,0 +1,417 @@
|
||||
import { duckdbQuery, duckdbPath, duckdbExec, parseRelationValue } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type ObjectRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
default_view?: string;
|
||||
display_field?: string;
|
||||
immutable?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
type FieldRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
enum_values?: string;
|
||||
enum_colors?: string;
|
||||
enum_multiple?: boolean;
|
||||
related_object_id?: string;
|
||||
relationship_type?: string;
|
||||
sort_order?: number;
|
||||
};
|
||||
|
||||
type StatusRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
sort_order?: number;
|
||||
is_default?: boolean;
|
||||
};
|
||||
|
||||
type EavRow = {
|
||||
entry_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
field_name: string;
|
||||
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.
|
||||
*/
|
||||
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);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
entry_id: row.entry_id,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
grouped.set(row.entry_id, entry);
|
||||
}
|
||||
if (row.field_name) {
|
||||
entry[row.field_name] = row.value;
|
||||
}
|
||||
}
|
||||
|
||||
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 }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
|
||||
if (!duckdbPath()) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB database not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize name to prevent injection (only allow alphanumeric + underscore)
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure display_field column exists (idempotent migration)
|
||||
ensureDisplayFieldColumn();
|
||||
|
||||
// Fetch object metadata
|
||||
const objects = duckdbQuery<ObjectRow>(
|
||||
`SELECT * FROM objects WHERE name = '${name}' LIMIT 1`,
|
||||
);
|
||||
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const obj = objects[0];
|
||||
|
||||
// Fetch fields for this object
|
||||
const fields = duckdbQuery<FieldRow>(
|
||||
`SELECT * FROM fields WHERE object_id = '${obj.id}' ORDER BY sort_order`,
|
||||
);
|
||||
|
||||
// Fetch statuses for this object
|
||||
const statuses = duckdbQuery<StatusRow>(
|
||||
`SELECT * FROM statuses WHERE object_id = '${obj.id}' ORDER BY sort_order`,
|
||||
);
|
||||
|
||||
// Try the PIVOT view first, then fall back to raw EAV query + client-side pivot
|
||||
let entries: Record<string, unknown>[] = [];
|
||||
|
||||
const pivotEntries = duckdbQuery(
|
||||
`SELECT * FROM v_${name} ORDER BY created_at DESC LIMIT 200`,
|
||||
);
|
||||
|
||||
if (pivotEntries.length > 0) {
|
||||
entries = pivotEntries;
|
||||
} else {
|
||||
const rawRows = duckdbQuery<EavRow>(
|
||||
`SELECT e.id as entry_id, e.created_at, e.updated_at,
|
||||
f.name as field_name, 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.object_id = '${obj.id}'
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 5000`,
|
||||
);
|
||||
|
||||
entries = pivotEavRows(rawRows);
|
||||
}
|
||||
|
||||
// Parse enum JSON strings in fields
|
||||
const parsedFields = fields.map((f) => ({
|
||||
...f,
|
||||
enum_values: f.enum_values ? tryParseJson(f.enum_values) : undefined,
|
||||
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: enrichedFields,
|
||||
statuses,
|
||||
entries,
|
||||
relationLabels,
|
||||
reverseRelations,
|
||||
effectiveDisplayField,
|
||||
});
|
||||
}
|
||||
43
apps/web/app/api/workspace/query/route.ts
Normal file
43
apps/web/app/api/workspace/query/route.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { duckdbQuery } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { sql?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Invalid JSON body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { sql } = body;
|
||||
if (!sql || typeof sql !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'sql' field in request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Basic SQL safety: reject obviously dangerous statements
|
||||
const upper = sql.toUpperCase().trim();
|
||||
if (
|
||||
upper.startsWith("DROP") ||
|
||||
upper.startsWith("DELETE") ||
|
||||
upper.startsWith("INSERT") ||
|
||||
upper.startsWith("UPDATE") ||
|
||||
upper.startsWith("ALTER") ||
|
||||
upper.startsWith("CREATE")
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "Only SELECT queries are allowed" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = duckdbQuery(sql);
|
||||
return Response.json({ rows });
|
||||
}
|
||||
122
apps/web/app/api/workspace/raw-file/route.ts
Normal file
122
apps/web/app/api/workspace/raw-file/route.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { safeResolvePath, resolveDenchRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
// Images
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
ico: "image/x-icon",
|
||||
bmp: "image/bmp",
|
||||
tiff: "image/tiff",
|
||||
tif: "image/tiff",
|
||||
avif: "image/avif",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
// Video
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
mov: "video/quicktime",
|
||||
avi: "video/x-msvideo",
|
||||
mkv: "video/x-matroska",
|
||||
// Audio
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
m4a: "audio/mp4",
|
||||
// Documents
|
||||
pdf: "application/pdf",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a file path, trying multiple strategies:
|
||||
* 1. Absolute path — the agent may read files from anywhere on the local machine
|
||||
* (Photos library, Downloads, etc.), so we serve any readable absolute path.
|
||||
* 2. Workspace-relative via safeResolvePath
|
||||
* 3. Bare filename — search common workspace subdirectories
|
||||
*
|
||||
* Security note: this is a local-only dev server; it never runs in production.
|
||||
*/
|
||||
function resolveFile(path: string): string | null {
|
||||
// 1. Absolute path — serve directly if it exists on disk
|
||||
if (path.startsWith("/")) {
|
||||
const abs = resolve(path);
|
||||
if (existsSync(abs)) {return abs;}
|
||||
// Fall through to workspace-relative in case the leading / is accidental
|
||||
}
|
||||
|
||||
// 2. Standard workspace-relative resolution
|
||||
const resolved = safeResolvePath(path);
|
||||
if (resolved) {return resolved;}
|
||||
|
||||
// 3. Try common subdirectories in case the path is a bare filename
|
||||
const root = resolveDenchRoot();
|
||||
if (!root) {return null;}
|
||||
const rootAbs = resolve(root);
|
||||
const basename = path.split("/").pop() ?? path;
|
||||
if (basename === path) {
|
||||
const subdirs = [
|
||||
"assets",
|
||||
"knowledge",
|
||||
"manufacturing",
|
||||
"uploads",
|
||||
"files",
|
||||
"images",
|
||||
"media",
|
||||
"reports",
|
||||
"exports",
|
||||
];
|
||||
for (const sub of subdirs) {
|
||||
const candidate = resolve(root, sub, basename);
|
||||
if (
|
||||
candidate.startsWith(rootAbs) &&
|
||||
existsSync(candidate)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/workspace/raw-file?path=...
|
||||
* Serves a workspace file with the correct Content-Type for inline display.
|
||||
* Used by the chain-of-thought component to render images, videos, and PDFs.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.searchParams.get("path");
|
||||
|
||||
if (!path) {
|
||||
return new Response("Missing path", { status: 400 });
|
||||
}
|
||||
|
||||
const absolute = resolveFile(path);
|
||||
if (!absolute) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
const contentType = MIME_MAP[ext] ?? "application/octet-stream";
|
||||
|
||||
try {
|
||||
const buffer = readFileSync(absolute);
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response("Read error", { status: 500 });
|
||||
}
|
||||
}
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
54
apps/web/app/api/workspace/reports/execute/route.ts
Normal file
54
apps/web/app/api/workspace/reports/execute/route.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { duckdbQuery } from "@/lib/workspace";
|
||||
import { buildFilterClauses, injectFilters, checkSqlSafety } from "@/lib/report-filters";
|
||||
import type { FilterEntry } from "@/lib/report-filters";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/workspace/reports/execute
|
||||
*
|
||||
* Body: { sql: string, filters?: FilterEntry[] }
|
||||
*
|
||||
* Executes a report panel's SQL query with optional filter injection.
|
||||
* Only SELECT-compatible queries are allowed.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
let body: {
|
||||
sql?: string;
|
||||
filters?: FilterEntry[];
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { sql, filters } = body;
|
||||
if (!sql || typeof sql !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'sql' field in request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Basic SQL safety: reject mutation statements
|
||||
const safetyError = checkSqlSafety(sql);
|
||||
if (safetyError) {
|
||||
return Response.json({ error: safetyError }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build filter clauses and inject into SQL
|
||||
const filterClauses = buildFilterClauses(filters);
|
||||
const finalSql = injectFilters(sql, filterClauses);
|
||||
|
||||
try {
|
||||
const rows = duckdbQuery(finalSql);
|
||||
return Response.json({ rows, sql: finalSql });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Query execution failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
266
apps/web/app/api/workspace/search-index/route.ts
Normal file
266
apps/web/app/api/workspace/search-index/route.ts
Normal file
@ -0,0 +1,266 @@
|
||||
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
resolveDenchRoot,
|
||||
parseSimpleYaml,
|
||||
duckdbQuery,
|
||||
duckdbPath,
|
||||
isDatabaseFile,
|
||||
} from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type SearchIndexItem = {
|
||||
/** Unique key: relative path for files, entryId for entries */
|
||||
id: string;
|
||||
/** Primary display text (filename or display-field value) */
|
||||
label: string;
|
||||
/** Secondary text (path for files, object name for entries) */
|
||||
sublabel?: string;
|
||||
/** Item kind for grouping and icons */
|
||||
kind: "file" | "object" | "entry";
|
||||
/** Icon hint */
|
||||
icon?: string;
|
||||
|
||||
// Entry-specific
|
||||
objectName?: string;
|
||||
entryId?: string;
|
||||
/** First few field key-value pairs for search and preview */
|
||||
fields?: Record<string, string>;
|
||||
|
||||
// File/object-specific
|
||||
path?: string;
|
||||
nodeType?: "document" | "folder" | "file" | "report" | "database";
|
||||
};
|
||||
|
||||
// --- DB types ---
|
||||
|
||||
type ObjectRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
default_view?: string;
|
||||
display_field?: string;
|
||||
};
|
||||
|
||||
type FieldRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
sort_order?: number;
|
||||
};
|
||||
|
||||
type EavRow = {
|
||||
entry_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
field_name: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/** Determine the display field (same heuristic as the objects route). */
|
||||
function resolveDisplayField(obj: ObjectRow, fields: FieldRow[]): string {
|
||||
if (obj.display_field) {return obj.display_field;}
|
||||
|
||||
const nameField = fields.find(
|
||||
(f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
|
||||
);
|
||||
if (nameField) {return nameField.name;}
|
||||
|
||||
const textField = fields.find((f) => f.type === "text");
|
||||
if (textField) {return textField.name;}
|
||||
|
||||
return fields[0]?.name ?? "id";
|
||||
}
|
||||
|
||||
/** Flatten a tree recursively to produce file/object search items. */
|
||||
function flattenTree(
|
||||
absDir: string,
|
||||
relBase: string,
|
||||
dbObjects: Map<string, ObjectRow>,
|
||||
items: SearchIndexItem[],
|
||||
) {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(absDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) {continue;}
|
||||
|
||||
const absPath = join(absDir, entry.name);
|
||||
const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const dbObj = dbObjects.get(entry.name);
|
||||
// Check for .object.yaml
|
||||
const yamlPath = join(absPath, ".object.yaml");
|
||||
const hasYaml = existsSync(yamlPath);
|
||||
|
||||
if (dbObj || hasYaml) {
|
||||
let icon: string | undefined;
|
||||
if (hasYaml) {
|
||||
try {
|
||||
const parsed = parseSimpleYaml(
|
||||
readFileSync(yamlPath, "utf-8"),
|
||||
);
|
||||
icon = parsed.icon as string | undefined;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: relPath,
|
||||
label: entry.name,
|
||||
sublabel: relPath,
|
||||
kind: "object",
|
||||
icon: icon ?? dbObj?.icon,
|
||||
path: relPath,
|
||||
nodeType: undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular folder -- don't add as item, but recurse
|
||||
}
|
||||
|
||||
flattenTree(absPath, relPath, dbObjects, items);
|
||||
} else if (entry.isFile()) {
|
||||
const isReport = entry.name.endsWith(".report.json");
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
const isDatabase = isDatabaseFile(entry.name);
|
||||
|
||||
items.push({
|
||||
id: relPath,
|
||||
label: entry.name.replace(/\.md$/, ""),
|
||||
sublabel: relPath,
|
||||
kind: "file",
|
||||
path: relPath,
|
||||
nodeType: isReport
|
||||
? "report"
|
||||
: isDatabase
|
||||
? "database"
|
||||
: isDocument
|
||||
? "document"
|
||||
: "file",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch all entries from all objects and produce search items. */
|
||||
function buildEntryItems(): SearchIndexItem[] {
|
||||
const items: SearchIndexItem[] = [];
|
||||
|
||||
const objects = duckdbQuery<ObjectRow>(
|
||||
"SELECT * FROM objects ORDER BY name",
|
||||
);
|
||||
|
||||
for (const obj of objects) {
|
||||
const fields = duckdbQuery<FieldRow>(
|
||||
`SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`,
|
||||
);
|
||||
const displayField = resolveDisplayField(obj, fields);
|
||||
// Pick the first few text-like fields for searchable preview (max 4)
|
||||
const previewFields = fields
|
||||
.filter((f) => !["relation", "richtext"].includes(f.type))
|
||||
.slice(0, 4);
|
||||
|
||||
// Try PIVOT view first, then raw EAV
|
||||
let entries: Record<string, unknown>[] = duckdbQuery(
|
||||
`SELECT * FROM v_${obj.name} ORDER BY created_at DESC LIMIT 500`,
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
const rawRows = duckdbQuery<EavRow>(
|
||||
`SELECT e.id as entry_id, e.created_at, e.updated_at,
|
||||
f.name as field_name, 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.object_id = '${sqlEscape(obj.id)}'
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 2500`,
|
||||
);
|
||||
|
||||
// Pivot manually
|
||||
const grouped = new Map<string, Record<string, unknown>>();
|
||||
for (const row of rawRows) {
|
||||
let entry = grouped.get(row.entry_id);
|
||||
if (!entry) {
|
||||
entry = { entry_id: row.entry_id };
|
||||
grouped.set(row.entry_id, entry);
|
||||
}
|
||||
if (row.field_name) {entry[row.field_name] = row.value;}
|
||||
}
|
||||
entries = Array.from(grouped.values());
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryId = String(entry.entry_id ?? "");
|
||||
if (!entryId) {continue;}
|
||||
|
||||
const displayValue = String(entry[displayField] ?? "");
|
||||
const fieldPreview: Record<string, string> = {};
|
||||
for (const f of previewFields) {
|
||||
const val = entry[f.name];
|
||||
if (val != null && val !== "") {
|
||||
fieldPreview[f.name] = String(val);
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: `entry:${obj.name}:${entryId}`,
|
||||
label: displayValue || `(${obj.name} entry)`,
|
||||
sublabel: obj.name,
|
||||
kind: "entry",
|
||||
icon: obj.icon,
|
||||
objectName: obj.name,
|
||||
entryId,
|
||||
fields: Object.keys(fieldPreview).length > 0 ? fieldPreview : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// --- Route handler ---
|
||||
|
||||
export async function GET() {
|
||||
const items: SearchIndexItem[] = [];
|
||||
|
||||
// 1. Files + objects from tree
|
||||
const root = resolveDenchRoot();
|
||||
if (root) {
|
||||
const dbObjects = new Map<string, ObjectRow>();
|
||||
if (duckdbPath()) {
|
||||
const objs = duckdbQuery<ObjectRow>(
|
||||
"SELECT * FROM objects",
|
||||
);
|
||||
for (const o of objs) {dbObjects.set(o.name, o);}
|
||||
}
|
||||
|
||||
// Scan entire dench root (the dench folder IS the knowledge base)
|
||||
flattenTree(root, "", dbObjects, items);
|
||||
}
|
||||
|
||||
// 2. Entries from all objects
|
||||
if (duckdbPath()) {
|
||||
items.push(...buildEntryItems());
|
||||
}
|
||||
|
||||
return Response.json({ items });
|
||||
}
|
||||
336
apps/web/app/api/workspace/tree/route.ts
Normal file
336
apps/web/app/api/workspace/tree/route.ts
Normal file
@ -0,0 +1,336 @@
|
||||
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveDenchRoot, parseSimpleYaml, duckdbQuery, isDatabaseFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string; // relative to dench/ (or ~skills/, ~memories/, ~workspace/ for virtual nodes)
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
/** Virtual nodes live outside the dench workspace (e.g. Skills, Memories). */
|
||||
virtual?: boolean;
|
||||
};
|
||||
|
||||
type DbObject = {
|
||||
name: string;
|
||||
icon?: string;
|
||||
default_view?: string;
|
||||
};
|
||||
|
||||
/** Read .object.yaml metadata from a directory if it exists. */
|
||||
function readObjectMeta(
|
||||
dirPath: string,
|
||||
): { icon?: string; defaultView?: string } | null {
|
||||
const yamlPath = join(dirPath, ".object.yaml");
|
||||
if (!existsSync(yamlPath)) {return null;}
|
||||
|
||||
try {
|
||||
const content = readFileSync(yamlPath, "utf-8");
|
||||
const parsed = parseSimpleYaml(content);
|
||||
return {
|
||||
icon: parsed.icon as string | undefined,
|
||||
defaultView: parsed.default_view as string | undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query DuckDB for all objects so we can identify object directories
|
||||
* even when .object.yaml files are missing.
|
||||
*/
|
||||
function loadDbObjects(): Map<string, DbObject> {
|
||||
const map = new Map<string, DbObject>();
|
||||
const rows = duckdbQuery<DbObject>(
|
||||
"SELECT name, icon, default_view FROM objects",
|
||||
);
|
||||
for (const row of rows) {
|
||||
map.set(row.name, row);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Recursively build a tree from a workspace directory. */
|
||||
function buildTree(
|
||||
absDir: string,
|
||||
relativeBase: string,
|
||||
dbObjects: Map<string, DbObject>,
|
||||
): TreeNode[] {
|
||||
const nodes: TreeNode[] = [];
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(absDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// Sort: directories first, then files, alphabetical within each group
|
||||
const sorted = entries
|
||||
.filter((e) => !e.name.startsWith(".") || e.name === ".object.yaml")
|
||||
.toSorted((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) {return -1;}
|
||||
if (!a.isDirectory() && b.isDirectory()) {return 1;}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
for (const entry of sorted) {
|
||||
// Skip hidden files except .object.yaml (but don't list it as a node)
|
||||
if (entry.name === ".object.yaml") {continue;}
|
||||
if (entry.name.startsWith(".")) {continue;}
|
||||
|
||||
const absPath = join(absDir, entry.name);
|
||||
const relPath = relativeBase
|
||||
? `${relativeBase}/${entry.name}`
|
||||
: entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const objectMeta = readObjectMeta(absPath);
|
||||
const dbObject = dbObjects.get(entry.name);
|
||||
const children = buildTree(absPath, relPath, dbObjects);
|
||||
|
||||
if (objectMeta || dbObject) {
|
||||
// This directory represents a CRM object (from .object.yaml OR DuckDB)
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: "object",
|
||||
icon: objectMeta?.icon ?? dbObject?.icon,
|
||||
defaultView:
|
||||
((objectMeta?.defaultView ?? dbObject?.default_view) as
|
||||
| "table"
|
||||
| "kanban") ?? "table",
|
||||
children: children.length > 0 ? children : undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular folder
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: "folder",
|
||||
children: children.length > 0 ? children : undefined,
|
||||
});
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isReport = entry.name.endsWith(".report.json");
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
const isDatabase = isDatabaseFile(entry.name);
|
||||
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// --- Virtual folder builders ---
|
||||
|
||||
/** Parse YAML frontmatter from a SKILL.md file (lightweight). */
|
||||
function parseSkillFrontmatter(content: string): { name?: string; emoji?: string } {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!match) {return {};}
|
||||
const yaml = match[1];
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of yaml.split("\n")) {
|
||||
const kv = line.match(/^(\w+)\s*:\s*(.+)/);
|
||||
if (kv) {result[kv[1]] = kv[2].replace(/^["']|["']$/g, "").trim();}
|
||||
}
|
||||
return { name: result.name, emoji: result.emoji };
|
||||
}
|
||||
|
||||
/** Build a virtual "Skills" folder from ~/.openclaw/skills/ and ~/.openclaw/workspace/skills/. */
|
||||
function buildSkillsVirtualFolder(): TreeNode | null {
|
||||
const home = homedir();
|
||||
const dirs = [
|
||||
join(home, ".openclaw", "skills"),
|
||||
join(home, ".openclaw", "workspace", "skills"),
|
||||
];
|
||||
|
||||
const children: TreeNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) {continue;}
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || seen.has(entry.name)) {continue;}
|
||||
const skillMdPath = join(dir, entry.name, "SKILL.md");
|
||||
if (!existsSync(skillMdPath)) {continue;}
|
||||
|
||||
seen.add(entry.name);
|
||||
let displayName = entry.name;
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, "utf-8");
|
||||
const meta = parseSkillFrontmatter(content);
|
||||
if (meta.name) {displayName = meta.name;}
|
||||
if (meta.emoji) {displayName = `${meta.emoji} ${displayName}`;}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
|
||||
children.push({
|
||||
name: displayName,
|
||||
path: `~skills/${entry.name}/SKILL.md`,
|
||||
type: "document",
|
||||
virtual: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// dir unreadable
|
||||
}
|
||||
}
|
||||
|
||||
if (children.length === 0) {return null;}
|
||||
children.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return {
|
||||
name: "Skills",
|
||||
path: "~skills",
|
||||
type: "folder",
|
||||
virtual: true,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build top-level workspace root file nodes (USER.md, SOUL.md, TOOLS.md, etc.).
|
||||
* These live directly in ~/.openclaw/workspace/ but outside the dench/ subdirectory.
|
||||
* They are virtual (not movable/renamable/deletable) but editable.
|
||||
*/
|
||||
function buildWorkspaceRootFiles(): TreeNode[] {
|
||||
const workspaceDir = join(homedir(), ".openclaw", "workspace");
|
||||
if (!existsSync(workspaceDir)) {return [];}
|
||||
|
||||
// Files already handled by the Memories virtual folder
|
||||
const SKIP_FILES = new Set(["MEMORY.md", "memory.md"]);
|
||||
|
||||
const nodes: TreeNode[] = [];
|
||||
|
||||
try {
|
||||
const entries = readdirSync(workspaceDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
// Skip subdirectories (handled elsewhere) and hidden files
|
||||
if (entry.isDirectory()) {continue;}
|
||||
if (entry.name.startsWith(".")) {continue;}
|
||||
if (SKIP_FILES.has(entry.name)) {continue;}
|
||||
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: `~workspace/${entry.name}`,
|
||||
type: isDocument ? "document" : "file",
|
||||
virtual: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// dir unreadable
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Build a virtual "Memories" folder from ~/.openclaw/workspace/. */
|
||||
function buildMemoriesVirtualFolder(): TreeNode | null {
|
||||
const workspaceDir = join(homedir(), ".openclaw", "workspace");
|
||||
const children: TreeNode[] = [];
|
||||
|
||||
// MEMORY.md
|
||||
for (const filename of ["MEMORY.md", "memory.md"]) {
|
||||
const memPath = join(workspaceDir, filename);
|
||||
if (existsSync(memPath)) {
|
||||
children.push({
|
||||
name: "MEMORY.md",
|
||||
path: `~memories/MEMORY.md`,
|
||||
type: "document",
|
||||
virtual: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Daily logs from memory/
|
||||
const memoryDir = join(workspaceDir, "memory");
|
||||
if (existsSync(memoryDir)) {
|
||||
try {
|
||||
const entries = readdirSync(memoryDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".md")) {continue;}
|
||||
children.push({
|
||||
name: entry.name,
|
||||
path: `~memories/${entry.name}`,
|
||||
type: "document",
|
||||
virtual: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// dir unreadable
|
||||
}
|
||||
}
|
||||
|
||||
if (children.length === 0) {return null;}
|
||||
// Sort: MEMORY.md first, then reverse chronological for daily logs
|
||||
children.sort((a, b) => {
|
||||
if (a.name === "MEMORY.md") {return -1;}
|
||||
if (b.name === "MEMORY.md") {return 1;}
|
||||
return b.name.localeCompare(a.name);
|
||||
});
|
||||
|
||||
return {
|
||||
name: "Memories",
|
||||
path: "~memories",
|
||||
type: "folder",
|
||||
virtual: true,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const root = resolveDenchRoot();
|
||||
if (!root) {
|
||||
// Even without a dench workspace, return virtual folders if they exist
|
||||
const tree: TreeNode[] = [];
|
||||
tree.push(...buildWorkspaceRootFiles());
|
||||
const skillsFolder = buildSkillsVirtualFolder();
|
||||
if (skillsFolder) {tree.push(skillsFolder);}
|
||||
const memoriesFolder = buildMemoriesVirtualFolder();
|
||||
if (memoriesFolder) {tree.push(memoriesFolder);}
|
||||
return Response.json({ tree, exists: false });
|
||||
}
|
||||
|
||||
// Load objects from DuckDB for smart directory detection
|
||||
const dbObjects = loadDbObjects();
|
||||
|
||||
// Scan the entire dench root -- the dench folder IS the knowledge base.
|
||||
// All top-level directories (manufacturing, knowledge, reports, etc.)
|
||||
// and files are visible in the sidebar.
|
||||
const tree = buildTree(root, "", dbObjects);
|
||||
|
||||
// Workspace root files (USER.md, SOUL.md, etc.) -- editable but reserved
|
||||
const workspaceRootFiles = buildWorkspaceRootFiles();
|
||||
if (workspaceRootFiles.length > 0) {tree.push(...workspaceRootFiles);}
|
||||
|
||||
// Virtual folders go after all real files/folders
|
||||
const skillsFolder = buildSkillsVirtualFolder();
|
||||
if (skillsFolder) {tree.push(skillsFolder);}
|
||||
const memoriesFolder = buildMemoriesVirtualFolder();
|
||||
if (memoriesFolder) {tree.push(memoriesFolder);}
|
||||
|
||||
return Response.json({ tree, exists: true });
|
||||
}
|
||||
86
apps/web/app/api/workspace/upload/route.ts
Normal file
86
apps/web/app/api/workspace/upload/route.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname, extname } from "node:path";
|
||||
import { resolveDenchRoot, safeResolveNewPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set([
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
|
||||
]);
|
||||
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/**
|
||||
* POST /api/workspace/upload
|
||||
* Accepts multipart form data with a "file" field.
|
||||
* Saves to assets/<timestamp>-<filename> inside the workspace.
|
||||
* Returns { ok, path } where path is workspace-relative.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
const root = resolveDenchRoot();
|
||||
if (!root) {
|
||||
return Response.json(
|
||||
{ error: "Workspace not found" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await req.formData();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid form data" }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!file || !(file instanceof File)) {
|
||||
return Response.json(
|
||||
{ error: "Missing 'file' field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate extension
|
||||
const ext = extname(file.name).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
||||
return Response.json(
|
||||
{ error: `File type ${ext} is not allowed` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (file.size > MAX_SIZE) {
|
||||
return Response.json(
|
||||
{ error: "File is too large (max 10 MB)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Build a safe filename: timestamp + sanitized original name
|
||||
const safeName = file.name
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.replace(/_{2,}/g, "_");
|
||||
const relPath = join("assets", `${Date.now()}-${safeName}`);
|
||||
|
||||
const absPath = safeResolveNewPath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "Invalid path" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(absPath), { recursive: true });
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
writeFileSync(absPath, buffer);
|
||||
return Response.json({ ok: true, path: relPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Upload failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
160
apps/web/app/api/workspace/virtual-file/route.ts
Normal file
160
apps/web/app/api/workspace/virtual-file/route.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import { join, dirname, resolve, normalize } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* Resolve a virtual path (~skills/... or ~memories/...) to an absolute filesystem path.
|
||||
* Returns null if the path is invalid or tries to escape.
|
||||
*/
|
||||
function resolveVirtualPath(virtualPath: string): string | null {
|
||||
const home = homedir();
|
||||
|
||||
if (virtualPath.startsWith("~skills/")) {
|
||||
// ~skills/<skillName>/SKILL.md
|
||||
const rest = virtualPath.slice("~skills/".length);
|
||||
// Validate: must be <name>/SKILL.md
|
||||
const parts = rest.split("/");
|
||||
if (parts.length !== 2 || parts[1] !== "SKILL.md" || !parts[0]) {
|
||||
return null;
|
||||
}
|
||||
const skillName = parts[0];
|
||||
// Prevent path traversal
|
||||
if (skillName.includes("..") || skillName.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check workspace skills first, then managed skills
|
||||
const candidates = [
|
||||
join(home, ".openclaw", "workspace", "skills", skillName, "SKILL.md"),
|
||||
join(home, ".openclaw", "skills", skillName, "SKILL.md"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// Default to workspace skills dir for new files
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
if (virtualPath.startsWith("~memories/")) {
|
||||
const rest = virtualPath.slice("~memories/".length);
|
||||
// Prevent path traversal
|
||||
if (rest.includes("..") || rest.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceDir = join(home, ".openclaw", "workspace");
|
||||
|
||||
if (rest === "MEMORY.md") {
|
||||
// Check both casing
|
||||
for (const filename of ["MEMORY.md", "memory.md"]) {
|
||||
const candidate = join(workspaceDir, filename);
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// Default to MEMORY.md for new files
|
||||
return join(workspaceDir, "MEMORY.md");
|
||||
}
|
||||
|
||||
// Daily log: must be a .md file in the memory/ subdirectory
|
||||
if (!rest.endsWith(".md")) {
|
||||
return null;
|
||||
}
|
||||
return join(workspaceDir, "memory", rest);
|
||||
}
|
||||
|
||||
if (virtualPath.startsWith("~workspace/")) {
|
||||
const rest = virtualPath.slice("~workspace/".length);
|
||||
// Only allow direct filenames (no subdirectories, no traversal)
|
||||
if (!rest || rest.includes("..") || rest.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
return join(home, ".openclaw", "workspace", rest);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Double-check that the resolved path stays within expected directories.
|
||||
*/
|
||||
function isSafePath(absPath: string): boolean {
|
||||
const home = homedir();
|
||||
const normalized = normalize(resolve(absPath));
|
||||
const allowed = [
|
||||
normalize(join(home, ".openclaw", "skills")),
|
||||
normalize(join(home, ".openclaw", "workspace", "skills")),
|
||||
normalize(join(home, ".openclaw", "workspace")),
|
||||
];
|
||||
return allowed.some((dir) => normalized.startsWith(dir));
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.searchParams.get("path");
|
||||
|
||||
if (!path) {
|
||||
return Response.json({ error: "Missing 'path' query parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const absPath = resolveVirtualPath(path);
|
||||
if (!absPath || !isSafePath(absPath)) {
|
||||
return Response.json({ error: "Invalid virtual path" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!existsSync(absPath)) {
|
||||
return Response.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(absPath, "utf-8");
|
||||
const ext = absPath.split(".").pop()?.toLowerCase();
|
||||
let type: "markdown" | "yaml" | "text" = "text";
|
||||
if (ext === "md" || ext === "mdx") {type = "markdown";}
|
||||
else if (ext === "yaml" || ext === "yml") {type = "yaml";}
|
||||
return Response.json({ content, type });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Read failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { path?: string; content?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: virtualPath, content } = body;
|
||||
if (!virtualPath || typeof virtualPath !== "string" || typeof content !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' and 'content' fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = resolveVirtualPath(virtualPath);
|
||||
if (!absPath || !isSafePath(absPath)) {
|
||||
return Response.json({ error: "Invalid virtual path" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(absPath), { recursive: true });
|
||||
writeFileSync(absPath, content, "utf-8");
|
||||
return Response.json({ ok: true, path: virtualPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Write 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
414
apps/web/app/components/charts/chart-panel.tsx
Normal file
414
apps/web/app/components/charts/chart-panel.tsx
Normal file
@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
RadarChart,
|
||||
Radar,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
FunnelChart,
|
||||
Funnel,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import type { PanelConfig } from "./types";
|
||||
|
||||
// --- Color palette derived from CSS variables + accessible defaults ---
|
||||
|
||||
const CHART_PALETTE = [
|
||||
"#2563eb", // accent
|
||||
"#60a5fa", // blue
|
||||
"#22c55e", // green
|
||||
"#f59e0b", // amber
|
||||
"#c084fc", // purple
|
||||
"#fb923c", // orange
|
||||
"#14b8a6", // teal
|
||||
"#f43f5e", // rose
|
||||
"#a78bfa", // violet
|
||||
"#38bdf8", // sky
|
||||
];
|
||||
|
||||
type ChartPanelProps = {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
/** Compact mode for inline chat cards */
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
// --- Shared tooltip/axis styles ---
|
||||
|
||||
const axisStyle = {
|
||||
fontSize: 11,
|
||||
fill: "var(--color-text-muted)",
|
||||
};
|
||||
|
||||
const gridStyle = {
|
||||
stroke: "var(--color-border-strong)",
|
||||
strokeDasharray: "3 3",
|
||||
};
|
||||
|
||||
function tooltipStyle() {
|
||||
return {
|
||||
contentStyle: {
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--color-text)",
|
||||
},
|
||||
itemStyle: { color: "var(--color-text)" },
|
||||
labelStyle: { color: "var(--color-text-muted)", marginBottom: 4 },
|
||||
};
|
||||
}
|
||||
|
||||
// --- Formatters ---
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
if (val === null || val === undefined) {return "";}
|
||||
if (typeof val === "number") {
|
||||
if (Math.abs(val) >= 1_000_000) {return `${(val / 1_000_000).toFixed(1)}M`;}
|
||||
if (Math.abs(val) >= 1_000) {return `${(val / 1_000).toFixed(1)}K`;}
|
||||
return Number.isInteger(val) ? String(val) : val.toFixed(2);
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function formatLabel(val: unknown): string {
|
||||
if (val === null || val === undefined) {return "";}
|
||||
const str = String(val);
|
||||
// Truncate long date strings
|
||||
if (str.length > 16 && !isNaN(Date.parse(str))) {
|
||||
return str.slice(0, 10);
|
||||
}
|
||||
// Truncate long labels
|
||||
if (str.length > 20) {return str.slice(0, 18) + "...";}
|
||||
return str;
|
||||
}
|
||||
|
||||
// --- Chart renderers ---
|
||||
|
||||
function CartesianChart({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
ChartComponent,
|
||||
SeriesComponent,
|
||||
areaProps,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
ChartComponent: typeof BarChart ;
|
||||
SeriesComponent: typeof Bar | typeof Line | typeof Area;
|
||||
areaProps?: Record<string, unknown>;
|
||||
}) {
|
||||
const { mapping } = config;
|
||||
const xKey = mapping.xAxis ?? Object.keys(data[0] ?? {})[0] ?? "x";
|
||||
const yKeys = mapping.yAxis ?? Object.keys(data[0] ?? {}).filter((k) => k !== xKey);
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ChartComponent data={data} margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid {...gridStyle} />
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
tick={axisStyle}
|
||||
tickFormatter={formatLabel}
|
||||
axisLine={{ stroke: "var(--color-border)" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={axisStyle}
|
||||
tickFormatter={formatValue}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={48}
|
||||
/>
|
||||
<Tooltip {...ttStyle} formatter={formatValue} labelFormatter={formatLabel} />
|
||||
{yKeys.length > 1 && !compact && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||
{yKeys.map((key, i) => {
|
||||
const color = colors[i % colors.length];
|
||||
const props: Record<string, unknown> = {
|
||||
key,
|
||||
dataKey: key,
|
||||
fill: color,
|
||||
stroke: color,
|
||||
name: key,
|
||||
...areaProps,
|
||||
};
|
||||
if (SeriesComponent === Bar) {
|
||||
props.radius = [4, 4, 0, 0];
|
||||
props.maxBarSize = 48;
|
||||
}
|
||||
if (SeriesComponent === Line) {
|
||||
props.strokeWidth = 2;
|
||||
props.dot = { r: 3, fill: color };
|
||||
props.activeDot = { r: 5 };
|
||||
}
|
||||
if (SeriesComponent === Area) {
|
||||
props.fillOpacity = 0.15;
|
||||
props.strokeWidth = 2;
|
||||
}
|
||||
// @ts-expect-error - dynamic component props
|
||||
return <SeriesComponent {...props} />;
|
||||
})}
|
||||
</ChartComponent>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function PieDonutChart({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { mapping, type } = config;
|
||||
const nameKey = mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name";
|
||||
const valueKey = mapping.valueKey ?? Object.keys(data[0] ?? {})[1] ?? "value";
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
const innerRadius = type === "donut" ? "50%" : 0;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey={valueKey}
|
||||
nameKey={nameKey}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={compact ? 70 : 110}
|
||||
paddingAngle={2}
|
||||
label={compact ? undefined : ((props: unknown) => {
|
||||
const p = props as Record<string, unknown>;
|
||||
const name = p.name;
|
||||
const percent = typeof p.percent === "number" ? p.percent : 0;
|
||||
return `${formatLabel(name)} ${(percent * 100).toFixed(0)}%`;
|
||||
}) as never}
|
||||
labelLine={!compact}
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={colors[i % colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip {...ttStyle} formatter={formatValue} />
|
||||
{!compact && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function RadarChartPanel({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { mapping } = config;
|
||||
const nameKey = mapping.xAxis ?? mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name";
|
||||
const valueKeys = mapping.yAxis ?? [Object.keys(data[0] ?? {})[1] ?? "value"];
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RadarChart data={data} cx="50%" cy="50%" outerRadius={compact ? 60 : 100}>
|
||||
<PolarGrid stroke="var(--color-border)" />
|
||||
<PolarAngleAxis dataKey={nameKey} tick={{ fontSize: 11, fill: "var(--color-text-muted)" }} />
|
||||
<PolarRadiusAxis tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} />
|
||||
{valueKeys.map((key, i) => (
|
||||
<Radar
|
||||
key={key}
|
||||
name={key}
|
||||
dataKey={key}
|
||||
stroke={colors[i % colors.length]}
|
||||
fill={colors[i % colors.length]}
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
))}
|
||||
<Tooltip {...ttStyle} />
|
||||
{!compact && valueKeys.length > 1 && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ScatterChartPanel({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { mapping } = config;
|
||||
const xKey = mapping.xAxis ?? Object.keys(data[0] ?? {})[0] ?? "x";
|
||||
const yKeys = mapping.yAxis ?? [Object.keys(data[0] ?? {})[1] ?? "y"];
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ScatterChart margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid {...gridStyle} />
|
||||
<XAxis dataKey={xKey} tick={axisStyle} name={xKey} axisLine={{ stroke: "var(--color-border)" }} tickLine={false} />
|
||||
<YAxis tick={axisStyle} tickFormatter={formatValue} axisLine={false} tickLine={false} width={48} />
|
||||
<Tooltip {...ttStyle} />
|
||||
{yKeys.map((key, i) => (
|
||||
<Scatter
|
||||
key={key}
|
||||
name={key}
|
||||
data={data}
|
||||
fill={colors[i % colors.length]}
|
||||
/>
|
||||
))}
|
||||
{!compact && yKeys.length > 1 && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function FunnelChartPanel({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { mapping } = config;
|
||||
const nameKey = mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name";
|
||||
const valueKey = mapping.valueKey ?? Object.keys(data[0] ?? {})[1] ?? "value";
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
|
||||
// Funnel expects data with fill colors
|
||||
const funnelData = data.map((row, i) => ({
|
||||
...row,
|
||||
fill: colors[i % colors.length],
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<FunnelChart>
|
||||
<Tooltip {...ttStyle} />
|
||||
<Funnel
|
||||
data={funnelData}
|
||||
dataKey={valueKey}
|
||||
nameKey={nameKey}
|
||||
isAnimationActive
|
||||
>
|
||||
<LabelList
|
||||
position="right"
|
||||
fill="var(--color-text-muted)"
|
||||
stroke="none"
|
||||
fontSize={11}
|
||||
dataKey={nameKey}
|
||||
/>
|
||||
</Funnel>
|
||||
</FunnelChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main ChartPanel component ---
|
||||
|
||||
export function ChartPanel({ config, data, compact }: ChartPanelProps) {
|
||||
// Coerce numeric values for Recharts
|
||||
const processedData = useMemo(() => {
|
||||
if (!data || data.length === 0) {return [];}
|
||||
const { mapping } = config;
|
||||
const numericKeys = new Set([
|
||||
...(mapping.yAxis ?? []),
|
||||
...(mapping.valueKey ? [mapping.valueKey] : []),
|
||||
]);
|
||||
|
||||
return data.map((row) => {
|
||||
const out: Record<string, unknown> = { ...row };
|
||||
for (const key of numericKeys) {
|
||||
if (key in out) {
|
||||
const v = out[key];
|
||||
if (typeof v === "string" && v !== "" && !isNaN(Number(v))) {
|
||||
out[key] = Number(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}, [data, config]);
|
||||
|
||||
if (processedData.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-xl"
|
||||
style={{
|
||||
height: compact ? 200 : 320,
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (config.type) {
|
||||
case "bar":
|
||||
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={BarChart} SeriesComponent={Bar} />;
|
||||
case "line":
|
||||
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={LineChart} SeriesComponent={Line} />;
|
||||
case "area":
|
||||
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={AreaChart} SeriesComponent={Area} />;
|
||||
case "pie":
|
||||
return <PieDonutChart config={config} data={processedData} compact={compact} />;
|
||||
case "donut":
|
||||
return <PieDonutChart config={config} data={processedData} compact={compact} />;
|
||||
case "radar":
|
||||
case "radialBar":
|
||||
return <RadarChartPanel config={config} data={processedData} compact={compact} />;
|
||||
case "scatter":
|
||||
return <ScatterChartPanel config={config} data={processedData} compact={compact} />;
|
||||
case "funnel":
|
||||
return <FunnelChartPanel config={config} data={processedData} compact={compact} />;
|
||||
default:
|
||||
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={BarChart} SeriesComponent={Bar} />;
|
||||
}
|
||||
}
|
||||
345
apps/web/app/components/charts/filter-bar.tsx
Normal file
345
apps/web/app/components/charts/filter-bar.tsx
Normal file
@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import type { FilterConfig, FilterState, FilterValue } from "./types";
|
||||
|
||||
type FilterBarProps = {
|
||||
filters: FilterConfig[];
|
||||
value: FilterState;
|
||||
onChange: (state: FilterState) => void;
|
||||
};
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function FilterIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Individual filter components ---
|
||||
|
||||
function DateRangeFilter({
|
||||
filter,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
filter: FilterConfig;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue) => void;
|
||||
}) {
|
||||
const current = value?.type === "dateRange" ? value : { type: "dateRange" as const };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
|
||||
{filter.label}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={current.from ?? ""}
|
||||
onChange={(e) => onChange({ ...current, from: e.target.value || undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
colorScheme: "dark",
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={current.to ?? ""}
|
||||
onChange={(e) => onChange({ ...current, to: e.target.value || undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
colorScheme: "dark",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectFilter({
|
||||
filter,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
filter: FilterConfig;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue) => void;
|
||||
options: string[];
|
||||
}) {
|
||||
const current = value?.type === "select" ? value.value : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
|
||||
{filter.label}
|
||||
</label>
|
||||
<select
|
||||
value={current ?? ""}
|
||||
onChange={(e) => onChange({ type: "select", value: e.target.value || undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none cursor-pointer"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
minWidth: 100,
|
||||
}}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectFilter({
|
||||
filter,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
filter: FilterConfig;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue) => void;
|
||||
options: string[];
|
||||
}) {
|
||||
const current = value?.type === "multiSelect" ? (value.values ?? []) : [];
|
||||
|
||||
const toggleOption = (opt: string) => {
|
||||
const next = current.includes(opt)
|
||||
? current.filter((v) => v !== opt)
|
||||
: [...current, opt];
|
||||
onChange({ type: "multiSelect", values: next.length > 0 ? next : undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
|
||||
{filter.label}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{options.map((opt) => {
|
||||
const selected = current.includes(opt);
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => toggleOption(opt)}
|
||||
className="px-2 py-0.5 rounded-full text-[10px] transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: selected ? "var(--color-accent-light)" : "var(--color-surface)",
|
||||
border: `1px solid ${selected ? "var(--color-accent)" : "var(--color-border)"}`,
|
||||
color: selected ? "var(--color-accent)" : "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberFilter({
|
||||
filter,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
filter: FilterConfig;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue) => void;
|
||||
}) {
|
||||
const current = value?.type === "number" ? value : { type: "number" as const };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
|
||||
{filter.label}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min"
|
||||
value={current.min ?? ""}
|
||||
onChange={(e) => onChange({ ...current, min: e.target.value ? Number(e.target.value) : undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none w-20"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>to</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max"
|
||||
value={current.max ?? ""}
|
||||
onChange={(e) => onChange({ ...current, max: e.target.value ? Number(e.target.value) : undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none w-20"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main FilterBar ---
|
||||
|
||||
export function FilterBar({ filters, value, onChange }: FilterBarProps) {
|
||||
// Fetch options for select/multiSelect filters
|
||||
const [optionsMap, setOptionsMap] = useState<Record<string, string[]>>({});
|
||||
|
||||
const fetchOptions = useCallback(async () => {
|
||||
const toFetch = filters.filter(
|
||||
(f) => (f.type === "select" || f.type === "multiSelect") && f.sql,
|
||||
);
|
||||
if (toFetch.length === 0) {return;}
|
||||
|
||||
const results: Record<string, string[]> = {};
|
||||
await Promise.all(
|
||||
toFetch.map(async (f) => {
|
||||
try {
|
||||
const res = await fetch("/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: f.sql }),
|
||||
});
|
||||
if (!res.ok) {return;}
|
||||
const data = await res.json();
|
||||
const rows: Record<string, unknown>[] = data.rows ?? [];
|
||||
// Extract the first column's values as options
|
||||
const opts = rows
|
||||
.map((r) => {
|
||||
const vals = Object.values(r);
|
||||
return vals[0] != null ? String(vals[0]) : null;
|
||||
})
|
||||
.filter((v): v is string => v !== null);
|
||||
results[f.id] = opts;
|
||||
} catch {
|
||||
// skip failed option fetches
|
||||
}
|
||||
}),
|
||||
);
|
||||
setOptionsMap(results);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptions();
|
||||
}, [fetchOptions]);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(filterId: string, v: FilterValue) => {
|
||||
onChange({ ...value, [filterId]: v });
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
const hasActiveFilters = Object.values(value).some((v) => {
|
||||
if (!v) {return false;}
|
||||
if (v.type === "dateRange") {return v.from || v.to;}
|
||||
if (v.type === "select") {return v.value;}
|
||||
if (v.type === "multiSelect") {return v.values && v.values.length > 0;}
|
||||
if (v.type === "number") {return v.min !== undefined || v.max !== undefined;}
|
||||
return false;
|
||||
});
|
||||
|
||||
const clearFilters = () => onChange({});
|
||||
|
||||
if (filters.length === 0) {return null;}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-4 px-4 py-2.5 border-b flex-wrap"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||
<FilterIcon />
|
||||
Filters
|
||||
</span>
|
||||
|
||||
{filters.map((filter) => {
|
||||
const fv = value[filter.id];
|
||||
switch (filter.type) {
|
||||
case "dateRange":
|
||||
return (
|
||||
<DateRangeFilter
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
value={fv}
|
||||
onChange={(v) => handleFilterChange(filter.id, v)}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<SelectFilter
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
value={fv}
|
||||
onChange={(v) => handleFilterChange(filter.id, v)}
|
||||
options={optionsMap[filter.id] ?? []}
|
||||
/>
|
||||
);
|
||||
case "multiSelect":
|
||||
return (
|
||||
<MultiSelectFilter
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
value={fv}
|
||||
onChange={(v) => handleFilterChange(filter.id, v)}
|
||||
options={optionsMap[filter.id] ?? []}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<NumberFilter
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
value={fv}
|
||||
onChange={(v) => handleFilterChange(filter.id, v)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors cursor-pointer"
|
||||
style={{
|
||||
color: "var(--color-accent)",
|
||||
background: "var(--color-accent-light)",
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
apps/web/app/components/charts/report-card.tsx
Normal file
289
apps/web/app/components/charts/report-card.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { ChartPanel } from "./chart-panel";
|
||||
import type { ReportConfig, PanelConfig } from "./types";
|
||||
|
||||
type ReportCardProps = {
|
||||
config: ReportConfig;
|
||||
};
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function ChartBarIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" 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>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLinkIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" x2="21" y1="14" y2="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PinIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="17" y2="22" />
|
||||
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Panel data state ---
|
||||
|
||||
type PanelData = {
|
||||
rows: Record<string, unknown>[];
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// --- Main ReportCard ---
|
||||
|
||||
export function ReportCard({ config }: ReportCardProps) {
|
||||
const [panelData, setPanelData] = useState<Record<string, PanelData>>({});
|
||||
const [pinning, setPinning] = useState(false);
|
||||
const [pinned, setPinned] = useState(false);
|
||||
|
||||
// Show at most 2 panels inline
|
||||
const visiblePanels = config.panels.slice(0, 2);
|
||||
|
||||
// Execute panel SQL queries
|
||||
const executePanels = useCallback(async () => {
|
||||
const initial: Record<string, PanelData> = {};
|
||||
for (const panel of visiblePanels) {
|
||||
initial[panel.id] = { rows: [], loading: true };
|
||||
}
|
||||
setPanelData(initial);
|
||||
|
||||
await Promise.all(
|
||||
visiblePanels.map(async (panel) => {
|
||||
try {
|
||||
const res = await fetch("/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: panel.sql }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: { rows: [], loading: false, error: data.error || `HTTP ${res.status}` },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: { rows: data.rows ?? [], loading: false },
|
||||
}));
|
||||
} catch (err) {
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: { rows: [], loading: false, error: err instanceof Error ? err.message : "Failed" },
|
||||
}));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, [visiblePanels]);
|
||||
|
||||
useEffect(() => {
|
||||
executePanels();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Pin report to workspace filesystem
|
||||
const handlePin = async () => {
|
||||
setPinning(true);
|
||||
try {
|
||||
const slug = config.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
const filename = `${slug}.report.json`;
|
||||
|
||||
await fetch("/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: `reports/${filename}`,
|
||||
content: JSON.stringify(config, null, 2),
|
||||
}),
|
||||
});
|
||||
setPinned(true);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setPinning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl overflow-hidden my-2"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 border-b"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span style={{ color: "#22c55e" }}>
|
||||
<ChartBarIcon />
|
||||
</span>
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{config.title}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: "rgba(34, 197, 94, 0.1)",
|
||||
color: "#22c55e",
|
||||
}}
|
||||
>
|
||||
{config.panels.length} chart{config.panels.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{!pinned && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePin}
|
||||
disabled={pinning}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] transition-colors cursor-pointer disabled:opacity-40"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
title="Save to workspace"
|
||||
>
|
||||
<PinIcon />
|
||||
{pinning ? "Saving..." : "Pin"}
|
||||
</button>
|
||||
)}
|
||||
{pinned && (
|
||||
<span
|
||||
className="text-[10px] px-2 py-1 rounded-md"
|
||||
style={{ color: "#22c55e", background: "rgba(34, 197, 94, 0.1)" }}
|
||||
>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href="/workspace"
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] transition-colors"
|
||||
style={{
|
||||
color: "var(--color-accent)",
|
||||
background: "var(--color-accent-light)",
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
Open
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{config.description && (
|
||||
<div className="px-3 py-1.5">
|
||||
<p className="text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Panels (compact mode) */}
|
||||
<div className={`grid gap-2 p-2 ${visiblePanels.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}>
|
||||
{visiblePanels.map((panel) => (
|
||||
<CompactPanelCard
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
data={panelData[panel.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* More panels indicator */}
|
||||
{config.panels.length > 2 && (
|
||||
<div
|
||||
className="px-3 py-1.5 text-center border-t"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
+{config.panels.length - 2} more chart{config.panels.length - 2 !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Compact panel card for inline rendering ---
|
||||
|
||||
function CompactPanelCard({
|
||||
panel,
|
||||
data,
|
||||
}: {
|
||||
panel: PanelConfig;
|
||||
data?: PanelData;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div className="px-2.5 py-1.5">
|
||||
<h4
|
||||
className="text-[11px] font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{panel.title}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
{data?.loading ? (
|
||||
<div className="flex items-center justify-center" style={{ height: 200 }}>
|
||||
<div
|
||||
className="w-4 h-4 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<div className="flex items-center justify-center" style={{ height: 200 }}>
|
||||
<p className="text-[10px]" style={{ color: "#f87171" }}>
|
||||
{data.error}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ChartPanel config={panel} data={data?.rows ?? []} compact />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
407
apps/web/app/components/charts/report-viewer.tsx
Normal file
407
apps/web/app/components/charts/report-viewer.tsx
Normal file
@ -0,0 +1,407 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { ChartPanel } from "./chart-panel";
|
||||
import { FilterBar } from "./filter-bar";
|
||||
import type { ReportConfig, FilterState, PanelConfig, FilterConfig } from "./types";
|
||||
|
||||
type ReportViewerProps = {
|
||||
/** Report config object (inline or loaded) */
|
||||
config?: ReportConfig;
|
||||
/** Path to load report config from filesystem */
|
||||
reportPath?: string;
|
||||
};
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function ChartBarIcon({ size = 20 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} 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>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
type PanelData = {
|
||||
panelId: string;
|
||||
rows: Record<string, unknown>[];
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Build filter entries for the API from active filter state + filter configs. */
|
||||
function buildFilterEntries(
|
||||
filterState: FilterState,
|
||||
filterConfigs: FilterConfig[],
|
||||
): Array<{ id: string; column: string; value: FilterState[string] }> {
|
||||
const entries: Array<{ id: string; column: string; value: FilterState[string] }> = [];
|
||||
for (const fc of filterConfigs) {
|
||||
const v = filterState[fc.id];
|
||||
if (!v) {continue;}
|
||||
// Only include if the filter has an active value
|
||||
const hasValue =
|
||||
(v.type === "dateRange" && (v.from || v.to)) ||
|
||||
(v.type === "select" && v.value) ||
|
||||
(v.type === "multiSelect" && v.values && v.values.length > 0) ||
|
||||
(v.type === "number" && (v.min !== undefined || v.max !== undefined));
|
||||
if (hasValue) {
|
||||
entries.push({ id: fc.id, column: fc.column, value: v });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// --- Grid size helpers ---
|
||||
|
||||
function panelColSpan(size?: string): string {
|
||||
switch (size) {
|
||||
case "full":
|
||||
return "col-span-6";
|
||||
case "third":
|
||||
return "col-span-2";
|
||||
case "half":
|
||||
default:
|
||||
return "col-span-3";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ReportViewer ---
|
||||
|
||||
export function ReportViewer({ config: propConfig, reportPath }: ReportViewerProps) {
|
||||
const [config, setConfig] = useState<ReportConfig | null>(propConfig ?? null);
|
||||
const [configLoading, setConfigLoading] = useState(!propConfig && !!reportPath);
|
||||
const [configError, setConfigError] = useState<string | null>(null);
|
||||
const [panelData, setPanelData] = useState<Record<string, PanelData>>({});
|
||||
const [filterState, setFilterState] = useState<FilterState>({});
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Load report config from filesystem if path provided
|
||||
useEffect(() => {
|
||||
if (propConfig) {
|
||||
setConfig(propConfig);
|
||||
return;
|
||||
}
|
||||
if (!reportPath) {return;}
|
||||
|
||||
let cancelled = false;
|
||||
setConfigLoading(true);
|
||||
setConfigError(null);
|
||||
|
||||
fetch(`/api/workspace/file?path=${encodeURIComponent(reportPath)}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {throw new Error(`Failed to load report: HTTP ${res.status}`);}
|
||||
const data = await res.json();
|
||||
if (cancelled) {return;}
|
||||
try {
|
||||
const parsed = JSON.parse(data.content) as ReportConfig;
|
||||
setConfig(parsed);
|
||||
} catch {
|
||||
throw new Error("Invalid report JSON");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setConfigError(err instanceof Error ? err.message : "Failed to load report");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {setConfigLoading(false);}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [propConfig, reportPath]);
|
||||
|
||||
// Execute all panel SQL queries when config or filters change
|
||||
const executeAllPanels = useCallback(async () => {
|
||||
if (!config) {return;}
|
||||
|
||||
const filterEntries = buildFilterEntries(filterState, config.filters ?? []);
|
||||
|
||||
// Mark all panels as loading
|
||||
const initialState: Record<string, PanelData> = {};
|
||||
for (const panel of config.panels) {
|
||||
initialState[panel.id] = { panelId: panel.id, rows: [], loading: true };
|
||||
}
|
||||
setPanelData(initialState);
|
||||
|
||||
// Execute all panels in parallel
|
||||
await Promise.all(
|
||||
config.panels.map(async (panel) => {
|
||||
try {
|
||||
const res = await fetch("/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sql: panel.sql,
|
||||
filters: filterEntries.length > 0 ? filterEntries : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: {
|
||||
panelId: panel.id,
|
||||
rows: [],
|
||||
loading: false,
|
||||
error: data.error || `HTTP ${res.status}`,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: {
|
||||
panelId: panel.id,
|
||||
rows: data.rows ?? [],
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: {
|
||||
panelId: panel.id,
|
||||
rows: [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "Query failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, [config, filterState]);
|
||||
|
||||
// Re-execute when config, filters, or refresh key changes
|
||||
useEffect(() => {
|
||||
executeAllPanels();
|
||||
}, [executeAllPanels, refreshKey]);
|
||||
|
||||
const totalRows = useMemo(() => {
|
||||
return Object.values(panelData).reduce((sum, pd) => sum + pd.rows.length, 0);
|
||||
}, [panelData]);
|
||||
|
||||
// --- Loading state ---
|
||||
if (configLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full gap-3">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Loading report...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Error state ---
|
||||
if (configError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 p-8">
|
||||
<ChartBarIcon size={48} />
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Failed to load report
|
||||
</p>
|
||||
<p
|
||||
className="text-xs px-3 py-2 rounded-lg max-w-md text-center"
|
||||
style={{ background: "var(--color-surface)", color: "#f87171" }}
|
||||
>
|
||||
{configError}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
No report configuration found
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Report header */}
|
||||
<div
|
||||
className="px-6 py-4 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5 mb-1">
|
||||
<span style={{ color: "var(--color-accent)" }}>
|
||||
<ChartBarIcon />
|
||||
</span>
|
||||
<h1
|
||||
className="text-xl font-bold"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{config.title}
|
||||
</h1>
|
||||
</div>
|
||||
{config.description && (
|
||||
<p
|
||||
className="text-sm ml-7"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-[10px] px-2 py-1 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{config.panels.length} panel{config.panels.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-2 py-1 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{totalRows} rows
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefreshKey((k) => k + 1)}
|
||||
className="p-1.5 rounded-md transition-colors cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{config.filters && config.filters.length > 0 && (
|
||||
<FilterBar
|
||||
filters={config.filters}
|
||||
value={filterState}
|
||||
onChange={setFilterState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Panel grid */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-6 gap-5">
|
||||
{config.panels.map((panel) => (
|
||||
<PanelCard
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
data={panelData[panel.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Individual panel card ---
|
||||
|
||||
function PanelCard({
|
||||
panel,
|
||||
data,
|
||||
}: {
|
||||
panel: PanelConfig;
|
||||
data?: PanelData;
|
||||
}) {
|
||||
const colSpan = panelColSpan(panel.size);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${colSpan} rounded-xl overflow-hidden`}
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{/* Panel header */}
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<h3
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{panel.title}
|
||||
</h3>
|
||||
{data && !data.loading && !data.error && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{data.rows.length} rows
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart area */}
|
||||
<div className="px-2 pb-3">
|
||||
{data?.loading ? (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ height: 320 }}
|
||||
>
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-2"
|
||||
style={{ height: 320 }}
|
||||
>
|
||||
<p className="text-xs" style={{ color: "#f87171" }}>
|
||||
Query error
|
||||
</p>
|
||||
<p
|
||||
className="text-[10px] px-2 py-1 rounded max-w-xs text-center"
|
||||
style={{ background: "rgba(248, 113, 113, 0.1)", color: "#f87171" }}
|
||||
>
|
||||
{data.error}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ChartPanel config={panel} data={data?.rows ?? []} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
apps/web/app/components/charts/types.ts
Normal file
64
apps/web/app/components/charts/types.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/** Shared types for the report/analytics system. */
|
||||
|
||||
export type ChartType =
|
||||
| "bar"
|
||||
| "line"
|
||||
| "area"
|
||||
| "pie"
|
||||
| "donut"
|
||||
| "radar"
|
||||
| "radialBar"
|
||||
| "scatter"
|
||||
| "funnel";
|
||||
|
||||
export type PanelSize = "full" | "half" | "third";
|
||||
|
||||
export type PanelMapping = {
|
||||
/** Key for x-axis or category axis */
|
||||
xAxis?: string;
|
||||
/** One or more keys for y-axis values (supports stacked/multi-series) */
|
||||
yAxis?: string[];
|
||||
/** Key used as label for pie/donut/funnel */
|
||||
nameKey?: string;
|
||||
/** Key used as value for pie/donut/funnel */
|
||||
valueKey?: string;
|
||||
/** Custom colors for series (hex). Falls back to palette. */
|
||||
colors?: string[];
|
||||
};
|
||||
|
||||
export type PanelConfig = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: ChartType;
|
||||
sql: string;
|
||||
mapping: PanelMapping;
|
||||
size?: PanelSize;
|
||||
};
|
||||
|
||||
export type FilterType = "dateRange" | "select" | "multiSelect" | "number";
|
||||
|
||||
export type FilterConfig = {
|
||||
id: string;
|
||||
type: FilterType;
|
||||
label: string;
|
||||
column: string;
|
||||
/** SQL to fetch available options (for select/multiSelect) */
|
||||
sql?: string;
|
||||
};
|
||||
|
||||
export type ReportConfig = {
|
||||
version: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
panels: PanelConfig[];
|
||||
filters?: FilterConfig[];
|
||||
};
|
||||
|
||||
/** Active filter values keyed by filter ID */
|
||||
export type FilterState = Record<string, FilterValue>;
|
||||
|
||||
export type FilterValue =
|
||||
| { type: "dateRange"; from?: string; to?: string }
|
||||
| { type: "select"; value?: string }
|
||||
| { type: "multiSelect"; values?: string[] }
|
||||
| { type: "number"; min?: number; max?: number };
|
||||
@ -1,18 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { UIMessage } from "ai";
|
||||
import type { Components } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ChainOfThought, type ChainPart } from "./chain-of-thought";
|
||||
import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
|
||||
import type { ReportConfig } from "./charts/types";
|
||||
|
||||
// Lazy-load ReportCard (uses Recharts which is heavy)
|
||||
const ReportCard = dynamic(
|
||||
() =>
|
||||
import("./charts/report-card").then((m) => ({
|
||||
default: m.ReportCard,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className="h-48 rounded-2xl animate-pulse"
|
||||
style={{ background: "var(--color-surface-hover)" }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
/* ─── Part grouping ─── */
|
||||
|
||||
type MessageSegment =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "chain"; parts: ChainPart[] };
|
||||
| { type: "chain"; parts: ChainPart[] }
|
||||
| { type: "report-artifact"; config: ReportConfig };
|
||||
|
||||
/** Map AI SDK tool state string to a simplified status */
|
||||
function toolStatus(state: string): "running" | "done" | "error" {
|
||||
if (state === "output-available") return "done";
|
||||
if (state === "error") return "error";
|
||||
if (state === "output-available") {
|
||||
return "done";
|
||||
}
|
||||
if (state === "error") {
|
||||
return "error";
|
||||
}
|
||||
return "running";
|
||||
}
|
||||
|
||||
@ -34,21 +62,42 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
flush();
|
||||
segments.push({
|
||||
type: "text",
|
||||
text: (part as { type: "text"; text: string }).text,
|
||||
});
|
||||
const text = (part as { type: "text"; text: string }).text;
|
||||
if (hasReportBlocks(text)) {
|
||||
segments.push(
|
||||
...(splitReportBlocks(text) as MessageSegment[]),
|
||||
);
|
||||
} else {
|
||||
segments.push({ type: "text", text });
|
||||
}
|
||||
} else if (part.type === "reasoning") {
|
||||
const rp = part as {
|
||||
type: "reasoning";
|
||||
text: string;
|
||||
state?: string;
|
||||
};
|
||||
chain.push({
|
||||
kind: "reasoning",
|
||||
text: rp.text,
|
||||
isStreaming: rp.state === "streaming",
|
||||
});
|
||||
// Detect status reasoning blocks emitted by lifecycle/compaction events.
|
||||
// These have short, specific labels — render as status indicators instead.
|
||||
const statusLabels = [
|
||||
"Preparing response...",
|
||||
"Optimizing session context...",
|
||||
];
|
||||
const isStatus = statusLabels.some((l) =>
|
||||
rp.text.startsWith(l),
|
||||
);
|
||||
if (isStatus) {
|
||||
chain.push({
|
||||
kind: "status",
|
||||
label: rp.text.split("\n")[0],
|
||||
isActive: rp.state === "streaming",
|
||||
});
|
||||
} else {
|
||||
chain.push({
|
||||
kind: "reasoning",
|
||||
text: rp.text,
|
||||
isStreaming: rp.state === "streaming",
|
||||
});
|
||||
}
|
||||
} else if (part.type === "dynamic-tool") {
|
||||
const tp = part as {
|
||||
type: "dynamic-tool";
|
||||
@ -98,56 +147,158 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
|
||||
function asRecord(
|
||||
val: unknown,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (val && typeof val === "object" && !Array.isArray(val))
|
||||
if (val && typeof val === "object" && !Array.isArray(val)) {
|
||||
return val as Record<string, unknown>;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/* ─── Chat message ─── */
|
||||
/* ─── Markdown component overrides for chat ─── */
|
||||
|
||||
const mdComponents: Components = {
|
||||
// Open external links in new tab
|
||||
a: ({ href, children, ...props }) => {
|
||||
const isExternal =
|
||||
href && (href.startsWith("http") || href.startsWith("//"));
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...(isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Render images with loading=lazy
|
||||
img: ({ src, alt, ...props }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src} alt={alt ?? ""} loading="lazy" {...props} />
|
||||
),
|
||||
};
|
||||
|
||||
/* ─── Chat message (Dench-inspired free-flowing text) ─── */
|
||||
|
||||
export function ChatMessage({ message }: { message: UIMessage }) {
|
||||
const isUser = message.role === "user";
|
||||
const segments = groupParts(message.parts);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex gap-3 py-4 ${isUser ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{!isUser && (
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-accent)] flex items-center justify-center text-white text-sm font-bold">
|
||||
O
|
||||
</div>
|
||||
)}
|
||||
if (isUser) {
|
||||
// User: right-aligned subtle pill (like Dench)
|
||||
const textContent = segments
|
||||
.filter(
|
||||
(s): s is { type: "text"; text: string } =>
|
||||
s.type === "text",
|
||||
)
|
||||
.map((s) => s.text)
|
||||
.join("\n");
|
||||
|
||||
<div
|
||||
className={`max-w-[75%] rounded-2xl px-4 py-3 ${
|
||||
isUser
|
||||
? "bg-[var(--color-accent)] text-white"
|
||||
: "bg-[var(--color-surface)] text-[var(--color-text)]"
|
||||
}`}
|
||||
>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === "text") {
|
||||
return (
|
||||
<div className="flex justify-end py-2">
|
||||
<div
|
||||
className="font-bookerly max-w-[80%] rounded-2xl rounded-br-sm px-4 py-2.5 text-[17px] leading-9"
|
||||
style={{
|
||||
background: "var(--color-user-bubble)",
|
||||
color: "var(--color-user-bubble-text)",
|
||||
}}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{textContent}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Assistant: free-flowing text, left-aligned, NO bubble
|
||||
return (
|
||||
<div className="py-3 space-y-2">
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === "text") {
|
||||
// Detect agent error messages
|
||||
const errorMatch = segment.text.match(
|
||||
/^\[error\]\s*([\s\S]*)$/,
|
||||
);
|
||||
if (errorMatch) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="whitespace-pre-wrap text-[15px] leading-relaxed"
|
||||
className="font-bookerly flex items-start gap-2 rounded-xl px-3 py-2 text-[13px] leading-relaxed"
|
||||
style={{
|
||||
background: `color-mix(in srgb, var(--color-error) 6%, var(--color-surface))`,
|
||||
color: "var(--color-error)",
|
||||
border: `1px solid color-mix(in srgb, var(--color-error) 18%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{segment.text}
|
||||
<span
|
||||
className="flex-shrink-0 mt-0.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="8"
|
||||
x2="12"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="16"
|
||||
x2="12.01"
|
||||
y2="16"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap">
|
||||
{errorMatch[1].trim()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ChainOfThought key={index} parts={segment.parts} />
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="chat-prose font-bookerly text-[17px]"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={mdComponents}
|
||||
>
|
||||
{segment.text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{isUser && (
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-border)] flex items-center justify-center text-[var(--color-text-muted)] text-sm font-bold">
|
||||
U
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
}
|
||||
if (segment.type === "report-artifact") {
|
||||
return (
|
||||
<ReportCard
|
||||
key={index}
|
||||
config={segment.config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ChainOfThought
|
||||
key={index}
|
||||
parts={segment.parts}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
863
apps/web/app/components/chat-panel.tsx
Normal file
863
apps/web/app/components/chat-panel.tsx
Normal file
@ -0,0 +1,863 @@
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport, type UIMessage } from "ai";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
|
||||
/** 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 [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;
|
||||
|
||||
// ── Ref-based session ID for transport ──
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = currentSessionId;
|
||||
}, [currentSessionId]);
|
||||
|
||||
// ── Transport (per-instance) ──
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
new DefaultChatTransport({
|
||||
api: "/api/chat",
|
||||
body: () => {
|
||||
const sid = sessionIdRef.current;
|
||||
return sid ? { sessionId: sid } : {};
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const { messages, sendMessage, status, stop, error, setMessages } =
|
||||
useChat({ transport });
|
||||
|
||||
const isStreaming =
|
||||
status === "streaming" || status === "submitted";
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
// ── Session persistence helpers ──
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
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?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to save messages:", err);
|
||||
}
|
||||
},
|
||||
[onSessionsChange],
|
||||
);
|
||||
|
||||
/** 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") ?? ""
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ── File-scoped session initialization ──
|
||||
const fetchFileSessionsRef = useRef<
|
||||
(() => Promise<FileScopedSession[]>) | null
|
||||
>(null);
|
||||
|
||||
fetchFileSessionsRef.current = async () => {
|
||||
if (!filePath) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/web-sessions?filePath=${encodeURIComponent(filePath)}`,
|
||||
);
|
||||
const data = await res.json();
|
||||
return (data.sessions || []) as FileScopedSession[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
sessionIdRef.current = null;
|
||||
setCurrentSessionId(null);
|
||||
onActiveSessionChange?.(null);
|
||||
setMessages([]);
|
||||
savedMessageIdsRef.current.clear();
|
||||
isFirstFileMessageRef.current = true;
|
||||
|
||||
(async () => {
|
||||
const sessions =
|
||||
(await fetchFileSessionsRef.current?.()) ?? [];
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setFileSessions(sessions);
|
||||
|
||||
if (sessions.length > 0) {
|
||||
const latest = sessions[0];
|
||||
setCurrentSessionId(latest.id);
|
||||
sessionIdRef.current = latest.id;
|
||||
onActiveSessionChange?.(latest.id);
|
||||
isFirstFileMessageRef.current = false;
|
||||
|
||||
try {
|
||||
const msgRes = await fetch(
|
||||
`/api/web-sessions/${latest.id}`,
|
||||
);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const msgData = await msgRes.json();
|
||||
const sessionMessages: Array<{
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
parts?: Array<Record<string, unknown>>;
|
||||
}> = msgData.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"],
|
||||
};
|
||||
},
|
||||
);
|
||||
if (!cancelled) {
|
||||
setMessages(uiMessages);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable setters
|
||||
}, [filePath]);
|
||||
|
||||
// ── Persist unsaved messages + live-reload after streaming ──
|
||||
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);
|
||||
}
|
||||
|
||||
if (filePath) {
|
||||
fetchFileSessionsRef.current?.().then(
|
||||
(sessions) => {
|
||||
setFileSessions(sessions);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let sessionId = currentSessionId;
|
||||
if (!sessionId) {
|
||||
const title =
|
||||
userText.length > 60
|
||||
? userText.slice(0, 60) + "..."
|
||||
: userText;
|
||||
sessionId = await createSession(title);
|
||||
setCurrentSessionId(sessionId);
|
||||
sessionIdRef.current = sessionId;
|
||||
onActiveSessionChange?.(sessionId);
|
||||
onSessionsChange?.();
|
||||
|
||||
if (filePath) {
|
||||
fetchFileSessionsRef.current?.().then(
|
||||
(sessions) => {
|
||||
setFileSessions(sessions);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
stop();
|
||||
setLoadingSession(true);
|
||||
setCurrentSessionId(sessionId);
|
||||
sessionIdRef.current = sessionId;
|
||||
onActiveSessionChange?.(sessionId);
|
||||
savedMessageIdsRef.current.clear();
|
||||
isFirstFileMessageRef.current = false;
|
||||
|
||||
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,
|
||||
stop,
|
||||
],
|
||||
);
|
||||
|
||||
const handleNewSession = useCallback(async () => {
|
||||
stop();
|
||||
setCurrentSessionId(null);
|
||||
sessionIdRef.current = null;
|
||||
onActiveSessionChange?.(null);
|
||||
setMessages([]);
|
||||
savedMessageIdsRef.current.clear();
|
||||
isFirstFileMessageRef.current = true;
|
||||
newSessionPendingRef.current = false;
|
||||
|
||||
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, stop]);
|
||||
|
||||
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"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
{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.5 rounded-lg"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
title="New chat"
|
||||
>
|
||||
<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-full font-medium`}
|
||||
style={{
|
||||
background:
|
||||
"var(--color-surface-hover)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
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.5 py-1 text-[10px] rounded-full whitespace-nowrap flex-shrink-0 font-medium"
|
||||
style={{
|
||||
background:
|
||||
s.id === currentSessionId
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-surface-hover)",
|
||||
color:
|
||||
s.id === currentSessionId
|
||||
? "white"
|
||||
: "var(--color-text-muted)",
|
||||
border:
|
||||
s.id === currentSessionId
|
||||
? "none"
|
||||
: "1px solid 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 max-w-md px-4">
|
||||
{compact ? (
|
||||
<p
|
||||
className="text-sm"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
Ask about this file
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<h3
|
||||
className="font-instrument text-3xl tracking-tight mb-2"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
What can I help with?
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm leading-relaxed"
|
||||
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>
|
||||
|
||||
{/* Transport-level error display */}
|
||||
{error && (
|
||||
<div
|
||||
className="px-3 py-2 border-t flex-shrink-0 flex items-center gap-2"
|
||||
style={{
|
||||
background: `color-mix(in srgb, var(--color-error) 6%, var(--color-surface))`,
|
||||
borderColor: `color-mix(in srgb, var(--color-error) 18%, transparent)`,
|
||||
color: "var(--color-error)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line
|
||||
x1="12"
|
||||
y1="8"
|
||||
x2="12"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="16"
|
||||
x2="12.01"
|
||||
y2="16"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-xs">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input — Dench-style rounded area with toolbar */}
|
||||
<div
|
||||
className={`${compact ? "px-3 py-2" : "px-6 py-4"} flex-shrink-0`}
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
<div
|
||||
className={`${compact ? "" : "max-w-3xl mx-auto"}`}
|
||||
>
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
background:
|
||||
"var(--color-chat-input-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) =>
|
||||
setInput(e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
compact && fileContext
|
||||
? `Ask about ${fileContext.filename}...`
|
||||
: "Ask anything..."
|
||||
}
|
||||
disabled={
|
||||
isStreaming ||
|
||||
loadingSession ||
|
||||
startingNewSession
|
||||
}
|
||||
className={`w-full ${compact ? "px-3 py-2.5 text-xs" : "px-4 py-3.5 text-sm"} bg-transparent outline-none placeholder:text-[var(--color-text-muted)] disabled:opacity-50`}
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
{/* Toolbar row */}
|
||||
<div
|
||||
className={`flex items-center justify-between ${compact ? "px-2 pb-1.5" : "px-3 pb-2.5"}`}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Placeholder toolbar icons */}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-lg"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
title="Attach"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* Send button */}
|
||||
<button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
!input.trim() ||
|
||||
isStreaming ||
|
||||
loadingSession ||
|
||||
startingNewSession
|
||||
}
|
||||
className={`${compact ? "w-6 h-6" : "w-7 h-7"} rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||
style={{
|
||||
background:
|
||||
input.trim()
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-border-strong)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<div
|
||||
className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 19V5" />
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 ---
|
||||
|
||||
@ -24,7 +25,16 @@ type MemoryFile = {
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
type SidebarSection = "chats" | "skills" | "memories";
|
||||
type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
};
|
||||
|
||||
type SidebarSection = "chats" | "skills" | "memories" | "workspace" | "reports";
|
||||
|
||||
type SidebarProps = {
|
||||
onSessionSelect?: (sessionId: string) => void;
|
||||
@ -38,11 +48,11 @@ type SidebarProps = {
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
if (seconds < 60) {return `${seconds}s ago`;}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (minutes < 60) {return `${minutes}m ago`;}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (hours < 24) {return `${hours}h ago`;}
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
@ -200,6 +210,95 @@ function MemoriesSection({
|
||||
);
|
||||
}
|
||||
|
||||
// --- Workspace Section (uses FileManagerTree in compact mode) ---
|
||||
|
||||
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)}`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-[var(--color-text-muted)] px-3 py-1">
|
||||
No workspace data yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<FileManagerTree
|
||||
tree={tree}
|
||||
activePath={null}
|
||||
onSelect={handleSelect}
|
||||
onRefresh={onRefresh}
|
||||
compact
|
||||
/>
|
||||
|
||||
{/* Full workspace link */}
|
||||
<a
|
||||
href="/workspace"
|
||||
className="flex items-center gap-1.5 mx-2 mt-2 px-2 py-1.5 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" x2="21" y1="14" y2="3" />
|
||||
</svg>
|
||||
Open full workspace
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Reports Section ---
|
||||
|
||||
function ReportsSection({ tree }: { tree: TreeNode[] }) {
|
||||
// Collect all report nodes from the tree (recursive)
|
||||
const reports: TreeNode[] = [];
|
||||
function collect(nodes: TreeNode[]) {
|
||||
for (const n of nodes) {
|
||||
if (n.type === "report") {reports.push(n);}
|
||||
if (n.children) {collect(n.children);}
|
||||
}
|
||||
}
|
||||
collect(tree);
|
||||
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-[var(--color-text-muted)] px-3 py-1">
|
||||
No reports yet. Ask the agent to create one.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{reports.map((report) => (
|
||||
<a
|
||||
key={report.path}
|
||||
href={`/workspace?path=${encodeURIComponent(report.path)}`}
|
||||
className="flex items-center gap-2 mx-2 px-2 py-1.5 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="flex-shrink-0" style={{ color: "#22c55e" }}>
|
||||
<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>
|
||||
</span>
|
||||
<span className="truncate flex-1">
|
||||
{report.name.replace(/\.report\.json$/, "")}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Collapsible Header ---
|
||||
|
||||
function SectionHeader({
|
||||
@ -246,18 +345,19 @@ export function Sidebar({
|
||||
activeSessionId,
|
||||
refreshKey,
|
||||
}: SidebarProps) {
|
||||
const [openSections, setOpenSections] = useState<Set<SidebarSection>>(new Set(["chats"]));
|
||||
const [openSections, setOpenSections] = useState<Set<SidebarSection>>(new Set(["chats", "workspace"]));
|
||||
const [webSessions, setWebSessions] = useState<WebSession[]>([]);
|
||||
const [skills, setSkills] = useState<SkillEntry[]>([]);
|
||||
const [mainMemory, setMainMemory] = useState<string | null>(null);
|
||||
const [dailyLogs, setDailyLogs] = useState<MemoryFile[]>([]);
|
||||
const [workspaceTree, setWorkspaceTree] = useState<TreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const toggleSection = (section: SidebarSection) => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) next.delete(section);
|
||||
else next.add(section);
|
||||
if (next.has(section)) {next.delete(section);}
|
||||
else {next.add(section);}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@ -267,15 +367,17 @@ export function Sidebar({
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [webSessionsRes, skillsRes, memoriesRes] = await Promise.all([
|
||||
const [webSessionsRes, skillsRes, memoriesRes, workspaceRes] = await Promise.all([
|
||||
fetch("/api/web-sessions").then((r) => r.json()),
|
||||
fetch("/api/skills").then((r) => r.json()),
|
||||
fetch("/api/memories").then((r) => r.json()),
|
||||
fetch("/api/workspace/tree").then((r) => r.json()).catch(() => ({ tree: [] })),
|
||||
]);
|
||||
setWebSessions(webSessionsRes.sessions ?? []);
|
||||
setSkills(skillsRes.skills ?? []);
|
||||
setMainMemory(memoriesRes.mainMemory ?? null);
|
||||
setDailyLogs(memoriesRes.dailyLogs ?? []);
|
||||
setWorkspaceTree(workspaceRes.tree ?? []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load sidebar data:", err);
|
||||
} finally {
|
||||
@ -285,13 +387,22 @@ 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 */}
|
||||
<div className="px-4 py-4 border-b border-[var(--color-border)] flex items-center justify-between">
|
||||
<h1 className="text-base font-bold flex items-center gap-2">
|
||||
<span className="text-xl">🦞</span>
|
||||
<span>OpenClaw</span>
|
||||
<span>Ironclaw</span>
|
||||
</h1>
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
@ -322,8 +433,23 @@ export function Sidebar({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Chats (web sessions) */}
|
||||
<div>
|
||||
{/* Workspace */}
|
||||
{workspaceTree.length > 0 && (
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Workspace"
|
||||
count={workspaceTree.length}
|
||||
isOpen={openSections.has("workspace")}
|
||||
onToggle={() => toggleSection("workspace")}
|
||||
/>
|
||||
{openSections.has("workspace") && (
|
||||
<WorkspaceSection tree={workspaceTree} onRefresh={refreshWorkspace} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chats (web sessions) */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Chats"
|
||||
count={webSessions.length}
|
||||
@ -339,6 +465,20 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reports */}
|
||||
{workspaceTree.length > 0 && (
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Reports"
|
||||
isOpen={openSections.has("reports")}
|
||||
onToggle={() => toggleSection("reports")}
|
||||
/>
|
||||
{openSections.has("reports") && (
|
||||
<ReportsSection tree={workspaceTree} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
|
||||
86
apps/web/app/components/workspace/breadcrumbs.tsx
Normal file
86
apps/web/app/components/workspace/breadcrumbs.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
type BreadcrumbsProps = {
|
||||
path: string;
|
||||
onNavigate: (path: string) => void;
|
||||
};
|
||||
|
||||
export function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate("")}
|
||||
className="px-1.5 py-0.5 rounded transition-colors cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--color-text)";
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
"var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color =
|
||||
"var(--color-text-muted)";
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
workspace
|
||||
</button>
|
||||
|
||||
{segments.map((segment, idx) => {
|
||||
const partialPath = segments.slice(0, idx + 1).join("/");
|
||||
const isLast = idx === segments.length - 1;
|
||||
|
||||
return (
|
||||
<span key={partialPath} className="flex items-center gap-1">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "var(--color-text-muted)", opacity: 0.4 }}
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
|
||||
{isLast ? (
|
||||
<span
|
||||
className="px-1.5 py-0.5"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{segment.replace(/\.md$/, "")}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(partialPath)}
|
||||
className="px-1.5 py-0.5 rounded transition-colors cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color =
|
||||
"var(--color-text)";
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
"var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color =
|
||||
"var(--color-text-muted)";
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
"transparent";
|
||||
}}
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
806
apps/web/app/components/workspace/data-table.tsx
Normal file
806
apps/web/app/components/workspace/data-table.tsx
Normal file
@ -0,0 +1,806 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type ColumnFiltersState,
|
||||
type VisibilityState,
|
||||
type Row,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
horizontalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { rankItem } from "@tanstack/match-sorter-utils";
|
||||
|
||||
/* ─── Types ─── */
|
||||
|
||||
export type RowAction<TData> = {
|
||||
label: string;
|
||||
onClick?: (row: TData) => void;
|
||||
icon?: React.ReactNode;
|
||||
variant?: "default" | "destructive";
|
||||
};
|
||||
|
||||
export type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
loading?: boolean;
|
||||
// search
|
||||
searchPlaceholder?: string;
|
||||
enableGlobalFilter?: boolean;
|
||||
// sorting
|
||||
enableSorting?: boolean;
|
||||
// row selection
|
||||
enableRowSelection?: boolean;
|
||||
rowSelection?: Record<string, boolean>;
|
||||
onRowSelectionChange?: OnChangeFn<Record<string, boolean>>;
|
||||
bulkActions?: React.ReactNode;
|
||||
// column features
|
||||
enableColumnReordering?: boolean;
|
||||
onColumnReorder?: (newOrder: string[]) => void;
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
// pagination
|
||||
pageSize?: number;
|
||||
// actions
|
||||
onRefresh?: () => void;
|
||||
onAdd?: () => void;
|
||||
addButtonLabel?: string;
|
||||
onRowClick?: (row: TData, index: number) => void;
|
||||
rowActions?: (row: TData) => RowAction<TData>[];
|
||||
// toolbar
|
||||
toolbarExtra?: React.ReactNode;
|
||||
title?: string;
|
||||
titleIcon?: React.ReactNode;
|
||||
// sticky
|
||||
stickyFirstColumn?: boolean;
|
||||
};
|
||||
|
||||
/* ─── Fuzzy filter ─── */
|
||||
|
||||
function fuzzyFilter(
|
||||
row: Row<unknown>,
|
||||
columnId: string,
|
||||
filterValue: string,
|
||||
) {
|
||||
const result = rankItem(row.getValue(columnId), filterValue);
|
||||
return result.passed;
|
||||
}
|
||||
|
||||
/* ─── Sortable header cell (DnD) ─── */
|
||||
|
||||
function SortableHeader({
|
||||
id,
|
||||
children,
|
||||
style,
|
||||
className,
|
||||
}: {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const dragStyle: React.CSSProperties = {
|
||||
...style,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
cursor: "grab",
|
||||
};
|
||||
|
||||
return (
|
||||
<th
|
||||
ref={setNodeRef}
|
||||
style={dragStyle}
|
||||
className={className}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Sort icon ─── */
|
||||
|
||||
function SortIcon({ direction }: { direction: "asc" | "desc" | false }) {
|
||||
if (!direction) {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.25 }}>
|
||||
<path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{direction === "asc" ? <path d="m5 12 7-7 7 7" /> : <path d="m19 12-7 7-7-7" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main component ─── */
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
loading = false,
|
||||
searchPlaceholder = "Search...",
|
||||
enableGlobalFilter = true,
|
||||
enableSorting = true,
|
||||
enableRowSelection = false,
|
||||
rowSelection: externalRowSelection,
|
||||
onRowSelectionChange,
|
||||
bulkActions,
|
||||
enableColumnReordering = false,
|
||||
onColumnReorder,
|
||||
initialColumnVisibility,
|
||||
pageSize: defaultPageSize = 100,
|
||||
onRefresh,
|
||||
onAdd,
|
||||
addButtonLabel = "+ Add",
|
||||
onRowClick,
|
||||
rowActions,
|
||||
toolbarExtra,
|
||||
title,
|
||||
titleIcon,
|
||||
stickyFirstColumn: stickyFirstProp = true,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(initialColumnVisibility ?? {});
|
||||
const [internalRowSelection, setInternalRowSelection] = useState<Record<string, boolean>>({});
|
||||
const [showColumnsMenu, setShowColumnsMenu] = useState(false);
|
||||
const [stickyFirstColumn, setStickyFirstColumn] = useState(stickyFirstProp);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: defaultPageSize });
|
||||
const columnsMenuRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const rowSelectionState = externalRowSelection !== undefined ? externalRowSelection : internalRowSelection;
|
||||
|
||||
// Extract column ID from ColumnDef
|
||||
const getColumnId = useCallback((c: ColumnDef<TData, TValue>): string => {
|
||||
if ("id" in c && typeof c.id === "string") {return c.id;}
|
||||
if ("accessorKey" in c && typeof c.accessorKey === "string") {return c.accessorKey;}
|
||||
return "";
|
||||
}, []);
|
||||
|
||||
// Column order for DnD — include "select" at start and "actions" at end
|
||||
// so TanStack doesn't push them to the end of the table
|
||||
const buildColumnOrder = useCallback(
|
||||
(dataCols: ColumnDef<TData, TValue>[]) => {
|
||||
const dataOrder = dataCols.map(getColumnId);
|
||||
const order: string[] = [];
|
||||
if (enableRowSelection) {order.push("select");}
|
||||
order.push(...dataOrder);
|
||||
if (rowActions) {order.push("actions");}
|
||||
return order;
|
||||
},
|
||||
[getColumnId, enableRowSelection, rowActions],
|
||||
);
|
||||
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(() =>
|
||||
buildColumnOrder(columns),
|
||||
);
|
||||
|
||||
// Update column order when columns change
|
||||
useEffect(() => {
|
||||
setColumnOrder(buildColumnOrder(columns));
|
||||
}, [columns, buildColumnOrder]);
|
||||
|
||||
// DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setColumnOrder((old) => {
|
||||
const oldIndex = old.indexOf(active.id as string);
|
||||
const newIndex = old.indexOf(over.id as string);
|
||||
const newOrder = arrayMove(old, oldIndex, newIndex);
|
||||
onColumnReorder?.(newOrder);
|
||||
return newOrder;
|
||||
});
|
||||
}
|
||||
},
|
||||
[onColumnReorder],
|
||||
);
|
||||
|
||||
// Scroll tracking for sticky column shadow
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
setIsScrolled(e.currentTarget.scrollLeft > 0);
|
||||
}, []);
|
||||
|
||||
// Close columns menu on click outside
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (columnsMenuRef.current && !columnsMenuRef.current.contains(e.target as Node)) {
|
||||
setShowColumnsMenu(false);
|
||||
}
|
||||
}
|
||||
if (showColumnsMenu) {
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}
|
||||
}, [showColumnsMenu]);
|
||||
|
||||
// Build selection column
|
||||
const selectionColumn: ColumnDef<TData> | null = enableRowSelection
|
||||
? {
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
||||
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)] cursor-pointer"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)] cursor-pointer"
|
||||
/>
|
||||
),
|
||||
size: 40,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Build actions column
|
||||
const actionsColumn: ColumnDef<TData> | null = rowActions
|
||||
? {
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<RowActionsMenu
|
||||
row={row.original}
|
||||
actions={rowActions(row.original)}
|
||||
/>
|
||||
),
|
||||
size: 48,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
}
|
||||
: null;
|
||||
|
||||
const allColumns = useMemo(() => {
|
||||
const cols: ColumnDef<TData, TValue>[] = [];
|
||||
if (selectionColumn) {cols.push(selectionColumn as ColumnDef<TData, TValue>);}
|
||||
cols.push(...columns);
|
||||
if (actionsColumn) {cols.push(actionsColumn as ColumnDef<TData, TValue>);}
|
||||
return cols;
|
||||
}, [columns, selectionColumn, actionsColumn]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: allColumns,
|
||||
state: {
|
||||
sorting,
|
||||
globalFilter,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection: rowSelectionState,
|
||||
columnOrder: enableColumnReordering ? columnOrder : undefined,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: (updater) => {
|
||||
if (onRowSelectionChange) {
|
||||
onRowSelectionChange(updater);
|
||||
} else {
|
||||
setInternalRowSelection(updater);
|
||||
}
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
enableRowSelection,
|
||||
enableSorting,
|
||||
globalFilterFn: fuzzyFilter,
|
||||
columnResizeMode: "onChange",
|
||||
});
|
||||
|
||||
const selectedCount = Object.keys(rowSelectionState).filter((k) => rowSelectionState[k]).length;
|
||||
const visibleColumns = table.getVisibleFlatColumns().filter((c) => c.id !== "select" && c.id !== "actions");
|
||||
|
||||
// ─── Render ───
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 flex-shrink-0 flex-wrap"
|
||||
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||
>
|
||||
{title && (
|
||||
<div className="flex items-center gap-2 mr-2">
|
||||
{titleIcon}
|
||||
<span className="text-sm font-semibold" style={{ color: "var(--color-text)" }}>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
{enableGlobalFilter && (
|
||||
<div className="relative flex-1 min-w-[180px] max-w-[320px]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: "var(--color-text-muted)" }}>
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full pl-9 pr-3 py-1.5 text-xs rounded-full outline-none"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
/>
|
||||
{globalFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGlobalFilter("")}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selectedCount > 0 && bulkActions && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
{bulkActions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{toolbarExtra}
|
||||
|
||||
{/* Refresh */}
|
||||
{onRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="p-1.5 rounded-lg"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Refresh"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /><path d="M3 21v-5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Columns menu */}
|
||||
<div className="relative" ref={columnsMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowColumnsMenu((v) => !v)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: showColumnsMenu ? "var(--color-surface-hover)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="M15 3v18" />
|
||||
</svg>
|
||||
Columns
|
||||
</button>
|
||||
{showColumnsMenu && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 z-50 min-w-[200px] rounded-xl overflow-hidden py-1"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
{/* Sticky first col toggle */}
|
||||
<label
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)", borderBottom: "1px solid var(--color-border)" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stickyFirstColumn}
|
||||
onChange={() => setStickyFirstColumn((v) => !v)}
|
||||
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)]"
|
||||
/>
|
||||
Freeze first column
|
||||
</label>
|
||||
{visibleColumns.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
No toggleable columns
|
||||
</div>
|
||||
) : (
|
||||
table.getAllLeafColumns()
|
||||
.filter((c) => c.id !== "select" && c.id !== "actions" && c.getCanHide())
|
||||
.map((column) => (
|
||||
<label
|
||||
key={column.id}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={column.getIsVisible()}
|
||||
onChange={column.getToggleVisibilityHandler()}
|
||||
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)]"
|
||||
/>
|
||||
{typeof column.columnDef.header === "string"
|
||||
? column.columnDef.header
|
||||
: column.id}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add button */}
|
||||
{onAdd && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium"
|
||||
style={{
|
||||
background: "var(--color-accent)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{addButtonLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSkeleton columnCount={allColumns.length} />
|
||||
) : data.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" /><path d="M9 3v18" />
|
||||
</svg>
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>No data</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<table className="w-full text-sm" style={{ borderCollapse: "separate", borderSpacing: 0 }}>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header, colIdx) => {
|
||||
const isFirstData = colIdx === (enableRowSelection ? 1 : 0);
|
||||
const isSticky = stickyFirstColumn && isFirstData;
|
||||
const isSelectCol = header.id === "select";
|
||||
const isActionsCol = header.id === "actions";
|
||||
const canSort = header.column.getCanSort();
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: isSticky || isSelectCol ? 31 : 30,
|
||||
...(isSticky ? { left: enableRowSelection ? 40 : 0, boxShadow: isScrolled ? "4px 0 8px -2px rgba(0,0,0,0.08)" : "none" } : {}),
|
||||
...(isSelectCol ? { left: 0, position: "sticky", zIndex: 31, width: 40 } : {}),
|
||||
width: header.getSize(),
|
||||
};
|
||||
|
||||
const content = header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext());
|
||||
|
||||
if (enableColumnReordering && !isSelectCol && !isActionsCol) {
|
||||
return (
|
||||
<SortableHeader
|
||||
key={header.id}
|
||||
id={header.id}
|
||||
style={headerStyle}
|
||||
className="text-left px-3 py-2.5 font-medium text-xs uppercase tracking-wider whitespace-nowrap border-b select-none"
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-1 ${canSort ? "cursor-pointer" : ""}`}
|
||||
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||
>
|
||||
{content}
|
||||
{canSort && <SortIcon direction={header.column.getIsSorted()} />}
|
||||
</span>
|
||||
</SortableHeader>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
style={headerStyle}
|
||||
className="text-left px-3 py-2.5 font-medium text-xs uppercase tracking-wider whitespace-nowrap border-b select-none"
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-1 ${canSort ? "cursor-pointer" : ""}`}
|
||||
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{content}
|
||||
{canSort && <SortIcon direction={header.column.getIsSorted()} />}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row, rowIdx) => {
|
||||
const isSelected = row.getIsSelected();
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={`transition-colors duration-75 ${onRowClick ? "cursor-pointer" : ""}`}
|
||||
style={{
|
||||
background: isSelected
|
||||
? "var(--color-accent-light)"
|
||||
: rowIdx % 2 === 0
|
||||
? "transparent"
|
||||
: "var(--color-surface)",
|
||||
}}
|
||||
onClick={() => onRowClick?.(row.original, rowIdx)}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected)
|
||||
{(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected)
|
||||
{(e.currentTarget as HTMLElement).style.background =
|
||||
rowIdx % 2 === 0 ? "transparent" : "var(--color-surface)";}
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell, colIdx) => {
|
||||
const isFirstData = colIdx === (enableRowSelection ? 1 : 0);
|
||||
const isSticky = stickyFirstColumn && isFirstData;
|
||||
const isSelectCol = cell.column.id === "select";
|
||||
|
||||
const cellStyle: React.CSSProperties = {
|
||||
borderColor: "var(--color-border)",
|
||||
...(isSticky
|
||||
? {
|
||||
position: "sticky" as const,
|
||||
left: enableRowSelection ? 40 : 0,
|
||||
zIndex: 20,
|
||||
background: isSelected
|
||||
? "var(--color-accent-light)"
|
||||
: "var(--color-bg)",
|
||||
boxShadow: isScrolled ? "4px 0 8px -2px rgba(0,0,0,0.08)" : "none",
|
||||
}
|
||||
: {}),
|
||||
...(isSelectCol
|
||||
? {
|
||||
position: "sticky" as const,
|
||||
left: 0,
|
||||
zIndex: 20,
|
||||
background: isSelected
|
||||
? "var(--color-accent-light)"
|
||||
: "var(--color-bg)",
|
||||
width: 40,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
className="px-3 py-2 border-b whitespace-nowrap"
|
||||
style={cellStyle}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination footer */}
|
||||
{!loading && data.length > 0 && (
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2 text-xs flex-shrink-0"
|
||||
style={{
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Showing {table.getRowModel().rows.length} of {data.length} results
|
||||
{selectedCount > 0 && ` (${selectedCount} selected)`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Rows per page</span>
|
||||
<select
|
||||
value={pagination.pageSize}
|
||||
onChange={(e) => setPagination((p) => ({ ...p, pageSize: Number(e.target.value), pageIndex: 0 }))}
|
||||
className="px-1.5 py-0.5 rounded-md text-xs outline-none"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{[20, 50, 100, 250, 500].map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
<span>
|
||||
Page {pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: 0 }))} disabled={!table.getCanPreviousPage()} label="«" />
|
||||
<PaginationButton onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} label="‹" />
|
||||
<PaginationButton onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} label="›" />
|
||||
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: table.getPageCount() - 1 }))} disabled={!table.getCanNextPage()} label="»" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Sub-components ─── */
|
||||
|
||||
function PaginationButton({ onClick, disabled, label }: { onClick: () => void; disabled: boolean; label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="w-6 h-6 rounded flex items-center justify-center text-xs disabled:opacity-30"
|
||||
style={{ color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }}
|
||||
// biome-ignore lint: using html entity label
|
||||
dangerouslySetInnerHTML={{ __html: label }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RowActionsMenu<TData>({ row, actions }: { row: TData; actions: RowAction<TData>[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
|
||||
className="p-1 rounded-md"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" /></svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 z-50 min-w-[140px] rounded-xl overflow-hidden py-1"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
{actions.map((action, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick?.(row);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left"
|
||||
style={{
|
||||
color: action.variant === "destructive" ? "var(--color-error)" : "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton({ columnCount }: { columnCount: number }) {
|
||||
return (
|
||||
<div className="p-4 space-y-2">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
{Array.from({ length: Math.min(columnCount, 6) }).map((_, j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="h-8 rounded-lg animate-pulse flex-1"
|
||||
style={{ background: "var(--color-surface-hover)", animationDelay: `${j * 50}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
918
apps/web/app/components/workspace/database-viewer.tsx
Normal file
918
apps/web/app/components/workspace/database-viewer.tsx
Normal file
@ -0,0 +1,918 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type ColumnInfo = {
|
||||
name: string;
|
||||
type: string;
|
||||
is_nullable: boolean;
|
||||
};
|
||||
|
||||
type TableInfo = {
|
||||
table_name: string;
|
||||
column_count: number;
|
||||
estimated_row_count: number;
|
||||
columns: ColumnInfo[];
|
||||
};
|
||||
|
||||
type SortState = {
|
||||
column: string;
|
||||
direction: "asc" | "desc";
|
||||
} | null;
|
||||
|
||||
type DatabaseViewerProps = {
|
||||
/** Relative path to the database file within the dench workspace */
|
||||
dbPath: string;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function DatabaseIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} 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>
|
||||
);
|
||||
}
|
||||
|
||||
function TableIcon() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ direction }: { direction: "left" | "right" }) {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{direction === "left" ? (
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
) : (
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SortIndicator({ active, direction }: { active: boolean; direction: "asc" | "desc" }) {
|
||||
return (
|
||||
<svg
|
||||
width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ opacity: active ? 1 : 0.25 }}
|
||||
>
|
||||
{direction === "asc" ? <path d="m5 12 7-7 7 7" /> : <path d="m19 12-7 7-7-7" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function formatRowCount(n: number): string {
|
||||
if (n >= 1_000_000) {return `${(n / 1_000_000).toFixed(1)}M`;}
|
||||
if (n >= 1_000) {return `${(n / 1_000).toFixed(1)}K`;}
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Map DuckDB type names to short display labels + color hints */
|
||||
function typeDisplay(dtype: string): { label: string; color: string } {
|
||||
const t = dtype.toUpperCase();
|
||||
if (t.includes("INT") || t.includes("BIGINT") || t.includes("SMALLINT") || t.includes("TINYINT") || t.includes("HUGEINT"))
|
||||
{return { label: "int", color: "#c084fc" };}
|
||||
if (t.includes("FLOAT") || t.includes("DOUBLE") || t.includes("DECIMAL") || t.includes("NUMERIC") || t.includes("REAL"))
|
||||
{return { label: "float", color: "#c084fc" };}
|
||||
if (t.includes("BOOL"))
|
||||
{return { label: "bool", color: "#f59e0b" };}
|
||||
if (t.includes("VARCHAR") || t.includes("TEXT") || t.includes("STRING") || t.includes("CHAR") || t === "UUID" || t === "BLOB")
|
||||
{return { label: t.includes("UUID") ? "uuid" : "text", color: "#22c55e" };}
|
||||
if (t.includes("TIMESTAMP") || t.includes("DATETIME"))
|
||||
{return { label: "timestamp", color: "#60a5fa" };}
|
||||
if (t.includes("DATE"))
|
||||
{return { label: "date", color: "#60a5fa" };}
|
||||
if (t.includes("TIME"))
|
||||
{return { label: "time", color: "#60a5fa" };}
|
||||
if (t.includes("JSON"))
|
||||
{return { label: "json", color: "#fb923c" };}
|
||||
return { label: dtype.toLowerCase(), color: "var(--color-text-muted)" };
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export function DatabaseViewer({ dbPath, filename }: DatabaseViewerProps) {
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Selected table
|
||||
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
||||
|
||||
// Table data
|
||||
const [tableData, setTableData] = useState<Record<string, unknown>[]>([]);
|
||||
const [dataLoading, setDataLoading] = useState(false);
|
||||
const [sort, setSort] = useState<SortState>(null);
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 100;
|
||||
|
||||
// Custom SQL query
|
||||
const [queryMode, setQueryMode] = useState(false);
|
||||
const [sqlInput, setSqlInput] = useState("");
|
||||
const [queryResult, setQueryResult] = useState<Record<string, unknown>[] | null>(null);
|
||||
const [queryError, setQueryError] = useState<string | null>(null);
|
||||
const [queryRunning, setQueryRunning] = useState(false);
|
||||
|
||||
// Schema panel toggle
|
||||
const [showSchema, setShowSchema] = useState(false);
|
||||
|
||||
// Fetch table list on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function introspect() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/workspace/db/introspect?path=${encodeURIComponent(dbPath)}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!cancelled) {
|
||||
setTables(data.tables ?? []);
|
||||
// Auto-select first table
|
||||
if (data.tables?.length > 0) {
|
||||
setSelectedTable(data.tables[0].table_name);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to introspect database");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {setLoading(false);}
|
||||
}
|
||||
}
|
||||
|
||||
introspect();
|
||||
return () => { cancelled = true; };
|
||||
}, [dbPath]);
|
||||
|
||||
// Fetch table data when selection or page changes
|
||||
const fetchTableData = useCallback(
|
||||
async (tableName: string, offset: number) => {
|
||||
setDataLoading(true);
|
||||
try {
|
||||
const safeName = tableName.replace(/"/g, '""');
|
||||
const sql = `SELECT * FROM "${safeName}" LIMIT ${pageSize} OFFSET ${offset}`;
|
||||
const res = await fetch("/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: dbPath, sql }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setTableData([]);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setTableData(data.rows ?? []);
|
||||
} catch {
|
||||
setTableData([]);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
},
|
||||
[dbPath],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTable) {
|
||||
setSort(null);
|
||||
fetchTableData(selectedTable, page * pageSize);
|
||||
}
|
||||
}, [selectedTable, page, fetchTableData]);
|
||||
|
||||
// Run custom query
|
||||
const runQuery = useCallback(async () => {
|
||||
if (!sqlInput.trim()) {return;}
|
||||
setQueryRunning(true);
|
||||
setQueryError(null);
|
||||
setQueryResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: dbPath, sql: sqlInput }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setQueryError(data.error || `HTTP ${res.status}`);
|
||||
} else {
|
||||
setQueryResult(data.rows ?? []);
|
||||
}
|
||||
} catch (err) {
|
||||
setQueryError(err instanceof Error ? err.message : "Query failed");
|
||||
} finally {
|
||||
setQueryRunning(false);
|
||||
}
|
||||
}, [dbPath, sqlInput]);
|
||||
|
||||
// Get selected table info
|
||||
const selectedTableInfo = useMemo(
|
||||
() => tables.find((t) => t.table_name === selectedTable) ?? null,
|
||||
[tables, selectedTable],
|
||||
);
|
||||
|
||||
// Sort client-side
|
||||
const sortedData = useMemo(() => {
|
||||
const data = queryMode && queryResult ? queryResult : tableData;
|
||||
if (!sort) {return data;}
|
||||
return [...data].toSorted((a, b) => {
|
||||
const aVal = String(a[sort.column] ?? "");
|
||||
const bVal = String(b[sort.column] ?? "");
|
||||
const cmp = aVal.localeCompare(bVal, undefined, { numeric: true });
|
||||
return sort.direction === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [queryMode, queryResult, tableData, sort]);
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
setSort((prev) => {
|
||||
if (prev?.column === column) {
|
||||
return prev.direction === "asc"
|
||||
? { column, direction: "desc" }
|
||||
: null;
|
||||
}
|
||||
return { column, direction: "asc" };
|
||||
});
|
||||
};
|
||||
|
||||
// Derive columns from data
|
||||
const dataColumns = useMemo(() => {
|
||||
const data = queryMode && queryResult ? queryResult : tableData;
|
||||
if (data.length === 0) {return [];}
|
||||
return Object.keys(data[0]);
|
||||
}, [queryMode, queryResult, tableData]);
|
||||
|
||||
// Detect database engine from filename
|
||||
const dbEngine = useMemo(() => {
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
if (ext === "duckdb") {return "DuckDB";}
|
||||
if (ext === "sqlite" || ext === "sqlite3") {return "SQLite";}
|
||||
if (ext === "postgres") {return "PostgreSQL";}
|
||||
if (ext === "db") {return "Database";}
|
||||
return "Database";
|
||||
}, [filename]);
|
||||
|
||||
// --- Loading state ---
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full gap-3">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Loading database...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Error state ---
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 p-8">
|
||||
<DatabaseIcon size={48} />
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Failed to open database
|
||||
</p>
|
||||
<p
|
||||
className="text-xs px-3 py-2 rounded-lg max-w-md text-center"
|
||||
style={{ background: "var(--color-surface)", color: "#f87171" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left panel: Table list */}
|
||||
<div
|
||||
className="w-56 flex-shrink-0 border-r flex flex-col overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
{/* Database header */}
|
||||
<div className="px-3 py-3 border-b flex items-center gap-2" style={{ borderColor: "var(--color-border)" }}>
|
||||
<span style={{ color: "var(--color-accent)" }}>
|
||||
<DatabaseIcon size={18} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</div>
|
||||
<div className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
{dbEngine} · {tables.length} table{tables.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table list */}
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{tables.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
No tables found
|
||||
</div>
|
||||
) : (
|
||||
tables.map((t) => {
|
||||
const isView = t.table_name.startsWith("v_");
|
||||
const isActive = selectedTable === t.table_name;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={t.table_name}
|
||||
onClick={() => {
|
||||
setSelectedTable(t.table_name);
|
||||
setPage(0);
|
||||
setQueryMode(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors duration-75 cursor-pointer"
|
||||
style={{
|
||||
background: isActive ? "var(--color-surface-hover)" : "transparent",
|
||||
color: isActive ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {(e.currentTarget as HTMLElement).style.background = "transparent";}
|
||||
}}
|
||||
>
|
||||
<span className="flex-shrink-0" style={{ color: isView ? "#60a5fa" : "var(--color-accent)" }}>
|
||||
{isView ? <ViewIcon /> : <TableIcon />}
|
||||
</span>
|
||||
<span className="truncate flex-1">{t.table_name}</span>
|
||||
<span className="flex-shrink-0 text-[10px] tabular-nums" style={{ color: "var(--color-text-muted)" }}>
|
||||
{formatRowCount(t.estimated_row_count)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Query mode toggle */}
|
||||
<div className="px-3 py-2 border-t" style={{ borderColor: "var(--color-border)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQueryMode(!queryMode)}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: queryMode ? "var(--color-accent-light)" : "var(--color-surface-hover)",
|
||||
color: queryMode ? "var(--color-accent)" : "var(--color-text-muted)",
|
||||
border: `1px solid ${queryMode ? "var(--color-accent)" : "var(--color-border)"}`,
|
||||
}}
|
||||
>
|
||||
<PlayIcon />
|
||||
SQL Query
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Data / Query */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{queryMode ? (
|
||||
<QueryPanel
|
||||
sqlInput={sqlInput}
|
||||
setSqlInput={setSqlInput}
|
||||
queryResult={queryResult}
|
||||
queryError={queryError}
|
||||
queryRunning={queryRunning}
|
||||
runQuery={runQuery}
|
||||
dataColumns={dataColumns}
|
||||
sortedData={sortedData}
|
||||
sort={sort}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
) : selectedTableInfo ? (
|
||||
<TableDataPanel
|
||||
table={selectedTableInfo}
|
||||
data={sortedData}
|
||||
dataLoading={dataLoading}
|
||||
dataColumns={dataColumns}
|
||||
sort={sort}
|
||||
onSort={handleSort}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
showSchema={showSchema}
|
||||
onToggleSchema={() => setShowSchema(!showSchema)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Select a table to view its data
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Table Data Panel ---
|
||||
|
||||
function TableDataPanel({
|
||||
table,
|
||||
data,
|
||||
dataLoading,
|
||||
dataColumns,
|
||||
sort,
|
||||
onSort,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
showSchema,
|
||||
onToggleSchema,
|
||||
}: {
|
||||
table: TableInfo;
|
||||
data: Record<string, unknown>[];
|
||||
dataLoading: boolean;
|
||||
dataColumns: string[];
|
||||
sort: SortState;
|
||||
onSort: (col: string) => void;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
showSchema: boolean;
|
||||
onToggleSchema: () => void;
|
||||
}) {
|
||||
const totalRows = table.estimated_row_count;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Table header bar */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span style={{ color: "var(--color-accent)" }}>
|
||||
<TableIcon />
|
||||
</span>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{table.table_name}
|
||||
</span>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{table.estimated_row_count.toLocaleString()} rows
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{table.column_count} columns
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSchema}
|
||||
className="text-[10px] px-2 py-0.5 rounded-full cursor-pointer transition-colors duration-100"
|
||||
style={{
|
||||
background: showSchema ? "rgba(96, 165, 250, 0.15)" : "var(--color-surface)",
|
||||
color: showSchema ? "#60a5fa" : "var(--color-text-muted)",
|
||||
border: `1px solid ${showSchema ? "#60a5fa" : "var(--color-border)"}`,
|
||||
}}
|
||||
>
|
||||
Schema
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schema panel (collapsible) */}
|
||||
{showSchema && (
|
||||
<div
|
||||
className="px-4 py-3 border-b overflow-x-auto flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
|
||||
{table.columns.map((col) => {
|
||||
const display = typeDisplay(col.type);
|
||||
return (
|
||||
<div key={col.name} className="flex items-center gap-1.5 text-xs">
|
||||
<span style={{ color: "var(--color-text-muted)" }}>
|
||||
<ColumnIcon />
|
||||
</span>
|
||||
<span className="font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{col.name}
|
||||
</span>
|
||||
<span
|
||||
className="px-1 py-px rounded text-[10px]"
|
||||
style={{ background: `${display.color}18`, color: display.color }}
|
||||
>
|
||||
{display.label}
|
||||
</span>
|
||||
{col.is_nullable && (
|
||||
<span className="text-[10px]" style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>
|
||||
null
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data table */}
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
{dataLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-2">
|
||||
<span style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
|
||||
<TableIcon />
|
||||
</span>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
No data
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={dataColumns}
|
||||
rows={data}
|
||||
sort={sort}
|
||||
onSort={onSort}
|
||||
schemaColumns={table.columns}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalRows > pageSize && (
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2 border-t flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
Page {page + 1} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className="p-1 rounded transition-colors duration-100 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<ChevronIcon direction="left" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className="p-1 rounded transition-colors duration-100 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<ChevronIcon direction="right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Query Panel ---
|
||||
|
||||
function QueryPanel({
|
||||
sqlInput,
|
||||
setSqlInput,
|
||||
queryResult,
|
||||
queryError,
|
||||
queryRunning,
|
||||
runQuery,
|
||||
dataColumns,
|
||||
sortedData,
|
||||
sort,
|
||||
onSort,
|
||||
}: {
|
||||
sqlInput: string;
|
||||
setSqlInput: (v: string) => void;
|
||||
queryResult: Record<string, unknown>[] | null;
|
||||
queryError: string | null;
|
||||
queryRunning: boolean;
|
||||
runQuery: () => void;
|
||||
dataColumns: string[];
|
||||
sortedData: Record<string, unknown>[];
|
||||
sort: SortState;
|
||||
onSort: (col: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* SQL input */}
|
||||
<div
|
||||
className="px-4 py-3 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={sqlInput}
|
||||
onChange={(e) => setSqlInput(e.target.value)}
|
||||
placeholder="SELECT * FROM table_name LIMIT 100"
|
||||
className="w-full text-xs rounded-lg px-3 py-2 resize-none outline-none"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
fontFamily: "'SF Mono', 'Fira Code', 'JetBrains Mono', monospace",
|
||||
minHeight: "60px",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
runQuery();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="text-[10px] mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Press Cmd+Enter to run
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runQuery}
|
||||
disabled={queryRunning || !sqlInput.trim()}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors duration-100 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: "var(--color-accent)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{queryRunning ? (
|
||||
<div
|
||||
className="w-3.5 h-3.5 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "rgba(255,255,255,0.3)", borderTopColor: "white" }}
|
||||
/>
|
||||
) : (
|
||||
<PlayIcon />
|
||||
)}
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{queryError && (
|
||||
<div className="px-4 py-3">
|
||||
<div
|
||||
className="px-3 py-2 rounded-lg text-xs"
|
||||
style={{ background: "rgba(248, 113, 113, 0.1)", color: "#f87171", border: "1px solid rgba(248, 113, 113, 0.2)" }}
|
||||
>
|
||||
{queryError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryResult !== null && queryResult.length === 0 && !queryError && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
Query returned no results
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryResult !== null && queryResult.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
{queryResult.length} row{queryResult.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={dataColumns}
|
||||
rows={sortedData}
|
||||
sort={sort}
|
||||
onSort={onSort}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{queryResult === null && !queryError && (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<span style={{ color: "var(--color-text-muted)", opacity: 0.3 }}>
|
||||
<PlayIcon />
|
||||
</span>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
Write a SQL query and press Run
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Shared Data Table ---
|
||||
|
||||
function DataTable({
|
||||
columns,
|
||||
rows,
|
||||
sort,
|
||||
onSort,
|
||||
schemaColumns,
|
||||
}: {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
sort: SortState;
|
||||
onSort: (col: string) => void;
|
||||
schemaColumns?: ColumnInfo[];
|
||||
}) {
|
||||
return (
|
||||
<table
|
||||
className="w-full text-xs"
|
||||
style={{ borderCollapse: "separate", borderSpacing: 0 }}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{/* Row number column */}
|
||||
<th
|
||||
className="text-right px-2 py-2 font-normal whitespace-nowrap border-b sticky top-0 z-[1]"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
width: "2.5rem",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
#
|
||||
</th>
|
||||
{columns.map((col) => {
|
||||
const schema = schemaColumns?.find((c) => c.name === col);
|
||||
const display = schema ? typeDisplay(schema.type) : null;
|
||||
return (
|
||||
<th
|
||||
key={col}
|
||||
className="text-left px-3 py-2 font-medium whitespace-nowrap border-b cursor-pointer select-none sticky top-0 z-[1]"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
}}
|
||||
onClick={() => onSort(col)}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{col}
|
||||
{display && (
|
||||
<span
|
||||
className="text-[9px] px-1 rounded"
|
||||
style={{ color: display.color, opacity: 0.6 }}
|
||||
>
|
||||
{display.label}
|
||||
</span>
|
||||
)}
|
||||
<SortIndicator
|
||||
active={sort?.column === col}
|
||||
direction={sort?.column === col ? sort.direction : "asc"}
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className="transition-colors duration-75 group"
|
||||
style={{
|
||||
background: idx % 2 === 0 ? "transparent" : "var(--color-surface)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
idx % 2 === 0 ? "transparent" : "var(--color-surface)";
|
||||
}}
|
||||
>
|
||||
{/* Row number */}
|
||||
<td
|
||||
className="text-right px-2 py-1.5 border-b tabular-nums"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
borderColor: "var(--color-border)",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
>
|
||||
{idx + 1}
|
||||
</td>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col}
|
||||
className="px-3 py-1.5 border-b whitespace-nowrap"
|
||||
style={{ borderColor: "var(--color-border)", color: "var(--color-text)" }}
|
||||
>
|
||||
<CellContent value={row[col]} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Cell content renderer ---
|
||||
|
||||
function CellContent({ value }: { value: unknown }) {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<span style={{ color: "var(--color-text-muted)", opacity: 0.4, fontStyle: "italic" }}>
|
||||
null
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return (
|
||||
<span style={{ color: value ? "#22c55e" : "#f87171" }}>
|
||||
{value ? "true" : "false"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return <span className="tabular-nums">{value}</span>;
|
||||
}
|
||||
|
||||
const str = String(value);
|
||||
|
||||
// Truncate very long values
|
||||
if (str.length > 120) {
|
||||
return (
|
||||
<span title={str} className="cursor-help">
|
||||
{str.slice(0, 120)}
|
||||
<span style={{ color: "var(--color-text-muted)" }}>...</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{str}</span>;
|
||||
}
|
||||
189
apps/web/app/components/workspace/document-view.tsx
Normal file
189
apps/web/app/components/workspace/document-view.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
|
||||
import { isWorkspaceLink } from "@/lib/workspace-links";
|
||||
import type { TreeNode, MentionSearchFn } from "./slash-command";
|
||||
|
||||
// Load markdown renderer client-only to avoid SSR issues with ESM-only packages
|
||||
const MarkdownContent = dynamic(
|
||||
() =>
|
||||
import("./markdown-content").then((mod) => mod.MarkdownContent),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="animate-pulse space-y-3 py-4">
|
||||
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "80%" }} />
|
||||
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "60%" }} />
|
||||
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "70%" }} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
// Lazy-load ReportCard (uses Recharts which is heavy)
|
||||
const ReportCard = dynamic(
|
||||
() =>
|
||||
import("../charts/report-card").then((m) => ({ default: m.ReportCard })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className="h-48 rounded-xl animate-pulse my-4"
|
||||
style={{ background: "var(--color-surface)" }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
// Lazy-load the Tiptap-based editor (heavy -- keep out of initial bundle)
|
||||
const MarkdownEditor = dynamic(
|
||||
() => import("./markdown-editor").then((m) => ({ default: m.MarkdownEditor })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="animate-pulse space-y-3 py-4 px-6">
|
||||
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "80%" }} />
|
||||
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "60%" }} />
|
||||
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "70%" }} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
type DocumentViewProps = {
|
||||
content: string;
|
||||
title?: string;
|
||||
filePath?: string;
|
||||
tree?: TreeNode[];
|
||||
onSave?: () => void;
|
||||
onNavigate?: (path: string) => void;
|
||||
searchFn?: MentionSearchFn;
|
||||
};
|
||||
|
||||
export function DocumentView({
|
||||
content,
|
||||
title,
|
||||
filePath,
|
||||
tree,
|
||||
onSave,
|
||||
onNavigate,
|
||||
searchFn,
|
||||
}: DocumentViewProps) {
|
||||
const [editMode, setEditMode] = useState(!!filePath);
|
||||
|
||||
// Strip YAML frontmatter if present
|
||||
const body = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "");
|
||||
|
||||
// Extract title from first H1 if no title provided
|
||||
const h1Match = body.match(/^#\s+(.+)/m);
|
||||
const displayTitle = title ?? h1Match?.[1];
|
||||
const markdownBody =
|
||||
displayTitle && h1Match ? body.replace(/^#\s+.+\n?/, "") : body;
|
||||
|
||||
// If we have a filePath and editing is enabled, render the Tiptap editor
|
||||
if (editMode && filePath) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<MarkdownEditor
|
||||
content={body}
|
||||
rawContent={content}
|
||||
filePath={filePath}
|
||||
tree={tree ?? []}
|
||||
onSave={onSave}
|
||||
onNavigate={onNavigate}
|
||||
searchFn={searchFn}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the markdown contains embedded report-json blocks
|
||||
const hasReports = hasReportBlocks(markdownBody);
|
||||
|
||||
// Intercept workspace-internal links in read mode (delegated click handler)
|
||||
const handleLinkClick = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!onNavigate) {return;}
|
||||
const target = event.target as HTMLElement;
|
||||
const link = target.closest("a");
|
||||
if (!link) {return;}
|
||||
const href = link.getAttribute("href");
|
||||
if (!href) {return;}
|
||||
if (isWorkspaceLink(href)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onNavigate(href);
|
||||
}
|
||||
},
|
||||
[onNavigate],
|
||||
);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div className="max-w-3xl mx-auto px-6 py-8" onClick={handleLinkClick}>
|
||||
{/* Header row with title + edit button */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{displayTitle && (
|
||||
<h1
|
||||
className="text-3xl font-bold mb-6 flex-1"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{displayTitle}
|
||||
</h1>
|
||||
)}
|
||||
{filePath && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode(true)}
|
||||
className="editor-mode-toggle flex-shrink-0 mt-1"
|
||||
title="Edit this document"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasReports ? (
|
||||
<EmbeddedReportContent content={markdownBody} />
|
||||
) : (
|
||||
<div className="workspace-prose">
|
||||
<MarkdownContent content={markdownBody} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders markdown content that contains embedded report-json blocks.
|
||||
* Splits the content into alternating markdown and interactive chart sections.
|
||||
*/
|
||||
function EmbeddedReportContent({ content }: { content: string }) {
|
||||
const segments = splitReportBlocks(content);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === "report-artifact") {
|
||||
return (
|
||||
<div key={index} className="my-6">
|
||||
<ReportCard config={segment.config} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Text segment -- render as markdown
|
||||
return (
|
||||
<div key={index} className="workspace-prose">
|
||||
<MarkdownContent content={segment.text} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
apps/web/app/components/workspace/empty-state.tsx
Normal file
156
apps/web/app/components/workspace/empty-state.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
export function EmptyState({
|
||||
workspaceExists,
|
||||
}: {
|
||||
workspaceExists: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-6 px-8">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl flex items-center justify-center"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "var(--shadow-sm)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect
|
||||
width="7"
|
||||
height="7"
|
||||
x="14"
|
||||
y="3"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
width="7"
|
||||
height="7"
|
||||
x="14"
|
||||
y="14"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
width="7"
|
||||
height="7"
|
||||
x="3"
|
||||
y="14"
|
||||
rx="1"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="text-center max-w-md">
|
||||
<h2
|
||||
className="font-instrument text-2xl tracking-tight mb-2"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{workspaceExists
|
||||
? "Workspace is empty"
|
||||
: "No workspace found"}
|
||||
</h2>
|
||||
<p
|
||||
className="text-sm leading-relaxed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{workspaceExists ? (
|
||||
<>
|
||||
The Dench workspace exists but has no
|
||||
knowledge tree yet. Ask the CRM agent to
|
||||
create objects and documents to populate
|
||||
it.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
The Dench workspace directory was not
|
||||
found. To initialize it, start a
|
||||
conversation with the CRM agent and it
|
||||
will create the workspace structure
|
||||
automatically.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hint */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
boxShadow: "var(--shadow-sm)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
color: "var(--color-accent)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
<span>
|
||||
Expected location:{" "}
|
||||
<code
|
||||
className="px-1.5 py-0.5 rounded-md text-xs"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
~/.openclaw/workspace/dench/
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Back link */}
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-sm mt-2"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
Back to Home
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
626
apps/web/app/components/workspace/entry-detail-modal.tsx
Normal file
626
apps/web/app/components/workspace/entry-detail-modal.tsx
Normal file
@ -0,0 +1,626 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type Field = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
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;
|
||||
links: Array<{ id: string; label: string }>;
|
||||
};
|
||||
|
||||
type EntryDetailData = {
|
||||
object: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
};
|
||||
fields: Field[];
|
||||
entry: Record<string, unknown>;
|
||||
relationLabels?: Record<string, Record<string, string>>;
|
||||
reverseRelations?: ReverseRelation[];
|
||||
effectiveDisplayField?: string;
|
||||
};
|
||||
|
||||
type EntryDetailModalProps = {
|
||||
objectName: string;
|
||||
entryId: string;
|
||||
members?: Array<{ id: string; name: string; email: string; role: string }>;
|
||||
onClose: () => void;
|
||||
/** Navigate to another entry (opens new modal). */
|
||||
onNavigateEntry?: (objectName: string, entryId: string) => void;
|
||||
/** Navigate to an object table view. */
|
||||
onNavigateObject?: (objectName: string) => void;
|
||||
/** Called after an edit or delete to refresh parent data. */
|
||||
onRefresh?: () => 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 JSON
|
||||
}
|
||||
}
|
||||
return [trimmed];
|
||||
}
|
||||
|
||||
// --- Cell renderers (lightweight variants of object-table ones) ---
|
||||
|
||||
function EnumBadge({
|
||||
value,
|
||||
enumValues,
|
||||
enumColors,
|
||||
}: {
|
||||
value: string;
|
||||
enumValues?: string[];
|
||||
enumColors?: string[];
|
||||
}) {
|
||||
const idx = enumValues?.indexOf(value) ?? -1;
|
||||
const color = idx >= 0 && enumColors ? enumColors[idx] : "#94a3b8";
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
background: `${color}20`,
|
||||
color,
|
||||
border: `1px solid ${color}40`,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function UserBadge({
|
||||
value,
|
||||
members,
|
||||
}: {
|
||||
value: unknown;
|
||||
members?: Array<{ id: string; name: string }>;
|
||||
}) {
|
||||
const memberId = String(value);
|
||||
const member = members?.find((m) => m.id === memberId);
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0"
|
||||
style={{ background: "var(--color-accent)", color: "white" }}
|
||||
>
|
||||
{(member?.name ?? memberId).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span>{member?.name ?? memberId}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RelationChips({
|
||||
value,
|
||||
field,
|
||||
relationLabels,
|
||||
onNavigateEntry,
|
||||
}: {
|
||||
value: unknown;
|
||||
field: Field;
|
||||
relationLabels?: Record<string, Record<string, string>>;
|
||||
onNavigateEntry?: (objectName: string, entryId: string) => void;
|
||||
}) {
|
||||
const fieldLabels = relationLabels?.[field.name];
|
||||
const ids = parseRelationValue(String(value));
|
||||
if (ids.length === 0) {return <EmptyValue />;}
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 flex-wrap">
|
||||
{ids.map((id) => {
|
||||
const label = fieldLabels?.[id] ?? id;
|
||||
const handleClick = field.related_object_name && onNavigateEntry
|
||||
? () => onNavigateEntry(field.related_object_name!, id)
|
||||
: undefined;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
onClick={handleClick}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${handleClick ? "cursor-pointer hover:opacity-80" : ""}`}
|
||||
style={{
|
||||
background: "rgba(96, 165, 250, 0.1)",
|
||||
color: "#60a5fa",
|
||||
border: "1px solid rgba(96, 165, 250, 0.2)",
|
||||
}}
|
||||
title={handleClick ? `Open ${label}` : label}
|
||||
>
|
||||
<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>
|
||||
<span className="truncate max-w-[200px]">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyValue() {
|
||||
return (
|
||||
<span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>--</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render a set of reverse relation links (incoming references from another object). */
|
||||
function ReverseRelationSection({
|
||||
relation,
|
||||
onNavigateEntry,
|
||||
}: {
|
||||
relation: ReverseRelation;
|
||||
onNavigateEntry?: (objectName: string, entryId: string) => void;
|
||||
}) {
|
||||
const displayLinks = relation.links.slice(0, 10);
|
||||
const overflow = relation.links.length - displayLinks.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider mb-1.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<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">{relation.sourceObjectName}</span>
|
||||
<span className="normal-case tracking-normal font-normal opacity-60">
|
||||
via {relation.fieldName}
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5 flex-wrap text-sm min-h-[1.75rem]">
|
||||
{displayLinks.map((link) => (
|
||||
<button
|
||||
type="button"
|
||||
key={link.id}
|
||||
onClick={() => onNavigateEntry?.(relation.sourceObjectName, link.id)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium cursor-pointer hover:opacity-80"
|
||||
style={{
|
||||
background: "rgba(192, 132, 252, 0.1)",
|
||||
color: "#c084fc",
|
||||
border: "1px solid rgba(192, 132, 252, 0.2)",
|
||||
}}
|
||||
title={`Open ${link.label} in ${relation.sourceObjectName}`}
|
||||
>
|
||||
<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>
|
||||
<span className="truncate max-w-[200px]">{link.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ color: "var(--color-text-muted)" }}>
|
||||
+{overflow} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldValue({
|
||||
value,
|
||||
field,
|
||||
members,
|
||||
relationLabels,
|
||||
onNavigateEntry,
|
||||
}: {
|
||||
value: unknown;
|
||||
field: Field;
|
||||
members?: Array<{ id: string; name: string }>;
|
||||
relationLabels?: Record<string, Record<string, string>>;
|
||||
onNavigateEntry?: (objectName: string, entryId: string) => void;
|
||||
}) {
|
||||
if (value === null || value === undefined || value === "") {return <EmptyValue />;}
|
||||
|
||||
switch (field.type) {
|
||||
case "enum":
|
||||
return (
|
||||
<EnumBadge
|
||||
value={String(value)}
|
||||
enumValues={field.enum_values}
|
||||
enumColors={field.enum_colors}
|
||||
/>
|
||||
);
|
||||
case "boolean": {
|
||||
const isTrue = value === true || value === "true" || value === "1" || value === "yes";
|
||||
return <span style={{ color: isTrue ? "#22c55e" : "var(--color-text-muted)" }}>{isTrue ? "Yes" : "No"}</span>;
|
||||
}
|
||||
case "user":
|
||||
return <UserBadge value={value} members={members} />;
|
||||
case "relation":
|
||||
return (
|
||||
<RelationChips
|
||||
value={value}
|
||||
field={field}
|
||||
relationLabels={relationLabels}
|
||||
onNavigateEntry={onNavigateEntry}
|
||||
/>
|
||||
);
|
||||
case "email":
|
||||
return (
|
||||
<a href={`mailto:${value}`} className="underline underline-offset-2" style={{ color: "#60a5fa" }}>
|
||||
{String(value)}
|
||||
</a>
|
||||
);
|
||||
case "richtext":
|
||||
return <span className="whitespace-pre-wrap">{String(value)}</span>;
|
||||
case "number":
|
||||
return <span className="tabular-nums">{String(value)}</span>;
|
||||
case "date":
|
||||
return <span>{String(value)}</span>;
|
||||
default:
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Modal Component ---
|
||||
|
||||
export function EntryDetailModal({
|
||||
objectName,
|
||||
entryId,
|
||||
members,
|
||||
onClose,
|
||||
onNavigateEntry,
|
||||
onNavigateObject,
|
||||
onRefresh,
|
||||
}: EntryDetailModalProps) {
|
||||
const [data, setData] = useState<EntryDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingField, setEditingField] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch entry data
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/workspace/objects/${encodeURIComponent(objectName)}/entries/${encodeURIComponent(entryId)}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Failed to load" }));
|
||||
if (!cancelled) {
|
||||
setError(err.error ?? "Failed to load entry");
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!cancelled) {
|
||||
setData(json);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError("Network error");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [objectName, entryId]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {onClose();}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on backdrop click
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === backdropRef.current) {onClose();}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
// ── Edit handler ──
|
||||
const handleSaveField = useCallback(async (fieldName: string, value: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/workspace/objects/${encodeURIComponent(objectName)}/entries/${encodeURIComponent(entryId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: { [fieldName]: value } }),
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
// Update local data optimistically
|
||||
setData((prev) => {
|
||||
if (!prev) {return prev;}
|
||||
return { ...prev, entry: { ...prev.entry, [fieldName]: value } };
|
||||
});
|
||||
setEditingField(null);
|
||||
onRefresh?.();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setSaving(false); }
|
||||
}, [objectName, entryId, onRefresh]);
|
||||
|
||||
// ── Delete handler ──
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!confirm("Are you sure you want to delete this entry?")) {return;}
|
||||
setDeleting(true);
|
||||
try {
|
||||
await fetch(
|
||||
`/api/workspace/objects/${encodeURIComponent(objectName)}/entries/${encodeURIComponent(entryId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
onRefresh?.();
|
||||
onClose();
|
||||
} catch { /* ignore */ }
|
||||
finally { setDeleting(false); }
|
||||
}, [objectName, entryId, onRefresh, onClose]);
|
||||
|
||||
const displayField = data?.effectiveDisplayField;
|
||||
const title = displayField && data?.entry[displayField]
|
||||
? String(data.entry[displayField])
|
||||
: `${objectName} entry`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
onClick={handleBackdropClick}
|
||||
className="fixed inset-0 z-50 flex items-start justify-center"
|
||||
style={{ background: "rgba(0, 0, 0, 0.5)", backdropFilter: "blur(2px)" }}
|
||||
>
|
||||
<div
|
||||
className="relative mt-12 mb-12 w-full max-w-2xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
maxHeight: "calc(100vh - 6rem)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* Object badge */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigateObject?.(objectName)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium capitalize transition-colors hover:opacity-80 flex-shrink-0"
|
||||
style={{
|
||||
background: "var(--color-accent-light)",
|
||||
color: "var(--color-accent)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
title={`Go to ${objectName}`}
|
||||
>
|
||||
<svg width="12" height="12" 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>
|
||||
{objectName}
|
||||
</button>
|
||||
<h2
|
||||
className="text-lg font-semibold truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{loading ? "Loading..." : title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Delete button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="p-1.5 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-error)" }}
|
||||
title="Delete entry"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Close"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{data && !loading && (
|
||||
<div className="space-y-4">
|
||||
{data.fields.map((field) => {
|
||||
const value = data.entry[field.name];
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wider mb-1.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{field.name}
|
||||
{field.type === "relation" && field.related_object_name && (
|
||||
<span className="normal-case tracking-normal font-normal opacity-60 ml-1">
|
||||
({field.related_object_name})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div
|
||||
className="text-sm min-h-[1.75rem] flex items-center"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{editingField === field.name ? (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); handleSaveField(field.name, editValue); }}
|
||||
className="flex items-center gap-2 w-full"
|
||||
>
|
||||
{field.type === "enum" && field.enum_values ? (
|
||||
<select
|
||||
value={editValue}
|
||||
onChange={(e) => { setEditValue(e.target.value); handleSaveField(field.name, e.target.value); }}
|
||||
autoFocus
|
||||
className="flex-1 px-2 py-1 text-sm rounded-lg outline-none"
|
||||
style={{ background: "var(--color-surface-hover)", color: "var(--color-text)", border: "2px solid var(--color-accent)" }}
|
||||
>
|
||||
<option value="">--</option>
|
||||
{field.enum_values.map((v) => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
) : field.type === "boolean" ? (
|
||||
<select
|
||||
value={editValue}
|
||||
onChange={(e) => { setEditValue(e.target.value); handleSaveField(field.name, e.target.value); }}
|
||||
autoFocus
|
||||
className="flex-1 px-2 py-1 text-sm rounded-lg outline-none"
|
||||
style={{ background: "var(--color-surface-hover)", color: "var(--color-text)", border: "2px solid var(--color-accent)" }}
|
||||
>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
autoFocus
|
||||
className="flex-1 px-2 py-1 text-sm rounded-lg outline-none"
|
||||
style={{ background: "var(--color-surface-hover)", color: "var(--color-text)", border: "2px solid var(--color-accent)" }}
|
||||
/>
|
||||
<button type="submit" disabled={saving} className="px-2 py-1 text-xs rounded-lg font-medium" style={{ background: "var(--color-accent)", color: "white" }}>
|
||||
{saving ? "..." : "Save"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" onClick={() => setEditingField(null)} className="px-2 py-1 text-xs rounded-lg" style={{ color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }}>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div
|
||||
className={`flex-1 ${!["relation", "user"].includes(field.type) ? "cursor-pointer hover:opacity-80" : ""}`}
|
||||
onClick={() => {
|
||||
if (!["relation", "user"].includes(field.type)) {
|
||||
setEditingField(field.name);
|
||||
setEditValue(String(value ?? ""));
|
||||
}
|
||||
}}
|
||||
title={!["relation", "user"].includes(field.type) ? "Click to edit" : undefined}
|
||||
>
|
||||
<FieldValue
|
||||
value={value}
|
||||
field={field}
|
||||
members={members}
|
||||
relationLabels={data.relationLabels}
|
||||
onNavigateEntry={onNavigateEntry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Reverse relations (incoming links from other objects) */}
|
||||
{data.reverseRelations && data.reverseRelations.length > 0 && (
|
||||
<div
|
||||
className="pt-4 mt-4 border-t space-y-4"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div
|
||||
className="text-[10px] font-medium uppercase tracking-widest"
|
||||
style={{ color: "var(--color-text-muted)", opacity: 0.6 }}
|
||||
>
|
||||
Linked from
|
||||
</div>
|
||||
{data.reverseRelations.map((rr) => (
|
||||
<ReverseRelationSection
|
||||
key={`${rr.sourceObjectName}_${rr.fieldName}`}
|
||||
relation={rr}
|
||||
onNavigateEntry={onNavigateEntry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
{(data.entry.created_at != null || data.entry.updated_at != null) && (
|
||||
<div
|
||||
className="pt-4 mt-4 border-t text-xs flex gap-6"
|
||||
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{data.entry.created_at != null && (
|
||||
<span>Created: {String(data.entry.created_at as string)}</span>
|
||||
)}
|
||||
{data.entry.updated_at != null && (
|
||||
<span>Updated: {String(data.entry.updated_at as string)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1063
apps/web/app/components/workspace/file-manager-tree.tsx
Normal file
1063
apps/web/app/components/workspace/file-manager-tree.tsx
Normal file
File diff suppressed because it is too large
Load Diff
168
apps/web/app/components/workspace/file-viewer.tsx
Normal file
168
apps/web/app/components/workspace/file-viewer.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
type FileViewerProps = {
|
||||
content: string;
|
||||
filename: string;
|
||||
type: "yaml" | "text";
|
||||
};
|
||||
|
||||
export function FileViewer({ content, filename, type }: FileViewerProps) {
|
||||
const lines = content.split("\n");
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
{/* File header */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-t-lg border border-b-0"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<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 className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded ml-auto"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* File content */}
|
||||
<div
|
||||
className="rounded-b-lg border overflow-x-auto"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<pre className="text-sm leading-6" style={{ margin: 0 }}>
|
||||
<code>
|
||||
{lines.map((line, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
|
||||
>
|
||||
{/* Line number */}
|
||||
<span
|
||||
className="select-none text-right pr-4 pl-4 flex-shrink-0 tabular-nums"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
opacity: 0.5,
|
||||
minWidth: "3rem",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
|
||||
{/* Line content */}
|
||||
<span
|
||||
className="pr-4 flex-1"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{type === "yaml" ? (
|
||||
<YamlLine line={line} />
|
||||
) : (
|
||||
line || " "
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Simple YAML syntax highlighting */
|
||||
function YamlLine({ line }: { line: string }) {
|
||||
// Comment
|
||||
if (line.trim().startsWith("#")) {
|
||||
return <span style={{ color: "var(--color-text-muted)" }}>{line}</span>;
|
||||
}
|
||||
|
||||
// Key: value
|
||||
const kvMatch = line.match(/^(\s*)([\w][\w_-]*)\s*(:)(.*)/);
|
||||
if (kvMatch) {
|
||||
const [, indent, key, colon, value] = kvMatch;
|
||||
return (
|
||||
<>
|
||||
<span>{indent}</span>
|
||||
<span style={{ color: "#60a5fa" }}>{key}</span>
|
||||
<span style={{ color: "var(--color-text-muted)" }}>{colon}</span>
|
||||
<YamlValue value={value} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// List item
|
||||
const listMatch = line.match(/^(\s*)(-)(\s*)(.*)/);
|
||||
if (listMatch) {
|
||||
const [, indent, dash, space, value] = listMatch;
|
||||
return (
|
||||
<>
|
||||
<span>{indent}</span>
|
||||
<span style={{ color: "var(--color-accent)" }}>{dash}</span>
|
||||
<span>{space}</span>
|
||||
<span style={{ color: "var(--color-text)" }}>{value}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{line || " "}</span>;
|
||||
}
|
||||
|
||||
function YamlValue({ value }: { value: string }) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
// String in quotes
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
return <span style={{ color: "#a5d6a7" }}> {trimmed}</span>;
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (trimmed === "true" || trimmed === "false") {
|
||||
return <span style={{ color: "#f59e0b" }}> {trimmed}</span>;
|
||||
}
|
||||
|
||||
// Number
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
return <span style={{ color: "#c084fc" }}> {trimmed}</span>;
|
||||
}
|
||||
|
||||
// Null
|
||||
if (trimmed === "null") {
|
||||
return (
|
||||
<span style={{ color: "var(--color-text-muted)", fontStyle: "italic" }}>
|
||||
{" "}
|
||||
{trimmed}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span style={{ color: "var(--color-text)" }}> {value}</span>;
|
||||
}
|
||||
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); }
|
||||
}
|
||||
`;
|
||||
289
apps/web/app/components/workspace/knowledge-tree.tsx
Normal file
289
apps/web/app/components/workspace/knowledge-tree.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
};
|
||||
|
||||
// --- Icons (inline SVG for zero-dep) ---
|
||||
|
||||
function FolderIcon({ open }: { open?: boolean }) {
|
||||
return open ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" 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>
|
||||
);
|
||||
}
|
||||
|
||||
function TableIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" 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>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" 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>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" 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>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" 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>
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" 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>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" 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>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
transform: open ? "rotate(90deg)" : "rotate(0deg)",
|
||||
transition: "transform 150ms ease",
|
||||
}}
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Node Icon Resolver ---
|
||||
|
||||
function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) {
|
||||
switch (node.type) {
|
||||
case "object":
|
||||
return node.defaultView === "kanban" ? <KanbanIcon /> : <TableIcon />;
|
||||
case "document":
|
||||
return <DocumentIcon />;
|
||||
case "folder":
|
||||
return <FolderIcon open={open} />;
|
||||
case "database":
|
||||
return <DatabaseIcon />;
|
||||
case "report":
|
||||
return <ReportIcon />;
|
||||
default:
|
||||
return <FileIcon />;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tree Node Component ---
|
||||
|
||||
function TreeNodeItem({
|
||||
node,
|
||||
depth,
|
||||
activePath,
|
||||
onSelect,
|
||||
expandedPaths,
|
||||
onToggleExpand,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
activePath: string | null;
|
||||
onSelect: (node: TreeNode) => void;
|
||||
expandedPaths: Set<string>;
|
||||
onToggleExpand: (path: string) => void;
|
||||
}) {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isExpandable = hasChildren || node.type === "folder" || node.type === "object";
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const isActive = activePath === node.path;
|
||||
|
||||
const handleClick = () => {
|
||||
onSelect(node);
|
||||
if (isExpandable) {
|
||||
onToggleExpand(node.path);
|
||||
}
|
||||
};
|
||||
|
||||
const typeColor =
|
||||
node.type === "object"
|
||||
? "var(--color-accent)"
|
||||
: node.type === "document"
|
||||
? "#60a5fa"
|
||||
: node.type === "database"
|
||||
? "#c084fc"
|
||||
: node.type === "report"
|
||||
? "#22c55e"
|
||||
: "var(--color-text-muted)";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="w-full flex items-center gap-1.5 py-1 px-2 rounded-md text-left text-sm transition-colors duration-100 cursor-pointer"
|
||||
style={{
|
||||
paddingLeft: `${depth * 16 + 8}px`,
|
||||
background: isActive ? "var(--color-surface-hover)" : "transparent",
|
||||
color: isActive ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive)
|
||||
{(e.currentTarget as HTMLElement).style.background =
|
||||
"var(--color-surface-hover)";}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive)
|
||||
{(e.currentTarget as HTMLElement).style.background = "transparent";}
|
||||
}}
|
||||
>
|
||||
{/* Expand/collapse chevron */}
|
||||
<span
|
||||
className="flex-shrink-0 w-4 h-4 flex items-center justify-center"
|
||||
style={{ opacity: isExpandable ? 1 : 0 }}
|
||||
>
|
||||
{isExpandable && <ChevronIcon open={isExpanded} />}
|
||||
</span>
|
||||
|
||||
{/* Icon */}
|
||||
<span
|
||||
className="flex-shrink-0 flex items-center"
|
||||
style={{ color: typeColor }}
|
||||
>
|
||||
<NodeIcon node={node} open={isExpanded} />
|
||||
</span>
|
||||
|
||||
{/* Label */}
|
||||
<span className="truncate flex-1">
|
||||
{node.name.replace(/\.md$/, "")}
|
||||
</span>
|
||||
|
||||
{/* Type badge for objects */}
|
||||
{node.type === "object" && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: "var(--color-accent-light)",
|
||||
color: "var(--color-accent)",
|
||||
}}
|
||||
>
|
||||
{node.defaultView === "kanban" ? "board" : "table"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Children */}
|
||||
{isExpanded && hasChildren && (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
borderLeft: depth > 0 ? "1px solid var(--color-border)" : "none",
|
||||
marginLeft: `${depth * 16 + 16}px`,
|
||||
}}
|
||||
>
|
||||
{node.children!.map((child) => (
|
||||
<TreeNodeItem
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activePath={activePath}
|
||||
onSelect={onSelect}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Exported Tree Component ---
|
||||
|
||||
export function KnowledgeTree({
|
||||
tree,
|
||||
activePath,
|
||||
onSelect,
|
||||
}: {
|
||||
tree: TreeNode[];
|
||||
activePath: string | null;
|
||||
onSelect: (node: TreeNode) => void;
|
||||
}) {
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback((path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {next.delete(path);}
|
||||
else {next.add(path);}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-6 text-center text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
No files in workspace
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-1">
|
||||
{tree.map((node) => (
|
||||
<TreeNodeItem
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
activePath={activePath}
|
||||
onSelect={onSelect}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
apps/web/app/components/workspace/markdown-content.tsx
Normal file
12
apps/web/app/components/workspace/markdown-content.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
export function MarkdownContent({ content }: { content: string }) {
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
713
apps/web/app/components/workspace/markdown-editor.tsx
Normal file
713
apps/web/app/components/workspace/markdown-editor.tsx
Normal file
@ -0,0 +1,713 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import TableRow from "@tiptap/extension-table-row";
|
||||
import TableCell from "@tiptap/extension-table-cell";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
|
||||
import { ReportBlockNode, preprocessReportBlocks, postprocessReportBlocks } from "./report-block-node";
|
||||
import { createSlashCommand, createWorkspaceMention, createFileMention, type TreeNode, type MentionSearchFn } from "./slash-command";
|
||||
import { isWorkspaceLink } from "@/lib/workspace-links";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type MarkdownEditorProps = {
|
||||
/** The markdown body (frontmatter already stripped by parent). */
|
||||
content: string;
|
||||
/** Original raw file content including frontmatter, used to preserve it on save. */
|
||||
rawContent?: string;
|
||||
filePath: string;
|
||||
tree: TreeNode[];
|
||||
onSave?: () => void;
|
||||
onNavigate?: (path: string) => void;
|
||||
/** Optional search function from useSearchIndex for fuzzy @ mention search. */
|
||||
searchFn?: MentionSearchFn;
|
||||
};
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
/** Extract YAML frontmatter (if any) from raw file content. */
|
||||
function extractFrontmatter(raw: string): string {
|
||||
const match = raw.match(/^(---\s*\n[\s\S]*?\n---\s*\n)/);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
export function MarkdownEditor({
|
||||
content,
|
||||
rawContent,
|
||||
filePath,
|
||||
tree,
|
||||
onSave,
|
||||
onNavigate,
|
||||
searchFn,
|
||||
}: MarkdownEditorProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle");
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
// Tracks the `content` prop so we can detect external updates (parent re-fetch).
|
||||
// Only updated when the prop itself changes -- never on save.
|
||||
const lastPropContentRef = useRef(content);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Preserve frontmatter so save can prepend it back
|
||||
const frontmatterRef = useRef(extractFrontmatter(rawContent ?? ""));
|
||||
|
||||
// "/" for block commands, "@" for workspace search (files + entries)
|
||||
const slashCommand = useMemo(() => createSlashCommand(), []);
|
||||
const fileMention = useMemo(
|
||||
() => searchFn ? createWorkspaceMention(searchFn) : createFileMention(tree),
|
||||
// searchFn from useSearchIndex is a stable ref-based function, so this
|
||||
// only re-runs on initial mount or if tree changes as fallback.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[searchFn, tree],
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
codeBlock: {
|
||||
HTMLAttributes: { class: "code-block" },
|
||||
},
|
||||
}),
|
||||
Markdown.configure({
|
||||
markedOptions: { gfm: true },
|
||||
}),
|
||||
Image.configure({
|
||||
inline: false,
|
||||
allowBase64: true,
|
||||
HTMLAttributes: { class: "editor-image" },
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
HTMLAttributes: {
|
||||
class: "editor-link",
|
||||
// Prevent browser from following workspace links as real URLs
|
||||
rel: "noopener",
|
||||
},
|
||||
}),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Placeholder.configure({
|
||||
placeholder: "Start writing, or type / for commands...",
|
||||
}),
|
||||
ReportBlockNode,
|
||||
slashCommand,
|
||||
fileMention,
|
||||
],
|
||||
// Parse initial content as markdown (not HTML -- the default)
|
||||
content: preprocessReportBlocks(content),
|
||||
contentType: "markdown",
|
||||
immediatelyRender: false,
|
||||
onUpdate: () => {
|
||||
setIsDirty(true);
|
||||
setSaveStatus("idle");
|
||||
},
|
||||
});
|
||||
|
||||
// --- Image upload helper ---
|
||||
const uploadImage = useCallback(
|
||||
async (file: File): Promise<string | null> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
try {
|
||||
const res = await fetch("/api/workspace/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {return null;}
|
||||
const data = await res.json();
|
||||
// Return a URL the browser can fetch to display the image
|
||||
return `/api/workspace/assets/${(data.path as string).replace(/^assets\//, "")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** Upload one or more image Files and insert them at the current cursor. */
|
||||
const insertUploadedImages = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (!editor) {return;}
|
||||
for (const file of files) {
|
||||
const url = await uploadImage(file);
|
||||
if (url) {
|
||||
editor.chain().focus().setImage({ src: url, alt: file.name }).run();
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor, uploadImage],
|
||||
);
|
||||
|
||||
// --- Drop & paste handlers for images ---
|
||||
useEffect(() => {
|
||||
if (!editor) {return;}
|
||||
|
||||
const editorElement = editor.view.dom;
|
||||
|
||||
// Prevent the browser default (open file in tab) and upload instead
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
if (!event.dataTransfer?.files?.length) {return;}
|
||||
|
||||
const imageFiles = Array.from(event.dataTransfer.files).filter((f) =>
|
||||
f.type.startsWith("image/"),
|
||||
);
|
||||
if (imageFiles.length === 0) {return;}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
insertUploadedImages(imageFiles);
|
||||
};
|
||||
|
||||
// Also prevent dragover so the browser doesn't hijack the drop
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
if (event.dataTransfer?.types?.includes("Files")) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
if (!event.clipboardData) {return;}
|
||||
|
||||
// 1. Handle pasted image files (e.g. screenshots)
|
||||
const imageFiles = Array.from(event.clipboardData.files).filter((f) =>
|
||||
f.type.startsWith("image/"),
|
||||
);
|
||||
if (imageFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
insertUploadedImages(imageFiles);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Handle pasted text that looks like a local image path or file:// URL
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
if (!text) {return;}
|
||||
|
||||
const isLocalPath =
|
||||
text.startsWith("file://") ||
|
||||
/^(\/|~\/|[A-Z]:\\).*\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(text.trim());
|
||||
|
||||
if (isLocalPath) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Insert as an image node directly -- the browser can't fetch file:// but
|
||||
// the user likely has the file accessible on their machine. We insert the
|
||||
// cleaned path; the asset serving route won't help here but at least the
|
||||
// markdown  will be correct.
|
||||
const cleanPath = text.trim().replace(/^file:\/\//, "");
|
||||
editor?.chain().focus().setImage({ src: cleanPath }).run();
|
||||
}
|
||||
};
|
||||
|
||||
editorElement.addEventListener("drop", handleDrop);
|
||||
editorElement.addEventListener("dragover", handleDragOver);
|
||||
editorElement.addEventListener("paste", handlePaste);
|
||||
return () => {
|
||||
editorElement.removeEventListener("drop", handleDrop);
|
||||
editorElement.removeEventListener("dragover", handleDragOver);
|
||||
editorElement.removeEventListener("paste", handlePaste);
|
||||
};
|
||||
}, [editor, insertUploadedImages]);
|
||||
|
||||
// Handle link clicks for workspace navigation.
|
||||
// Links are real URLs like /workspace?path=... so clicking them navigates
|
||||
// within the same tab. We intercept to avoid a full page reload.
|
||||
useEffect(() => {
|
||||
if (!editor || !onNavigate) {return;}
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const link = target.closest("a");
|
||||
if (!link) {return;}
|
||||
|
||||
const href = link.getAttribute("href");
|
||||
if (!href) {return;}
|
||||
|
||||
// Intercept /workspace?... links to handle via client-side state
|
||||
if (isWorkspaceLink(href)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onNavigate(href);
|
||||
}
|
||||
};
|
||||
|
||||
const editorElement = editor.view.dom;
|
||||
editorElement.addEventListener("click", handleClick, true);
|
||||
return () => editorElement.removeEventListener("click", handleClick, true);
|
||||
}, [editor, onNavigate]);
|
||||
|
||||
// Save handler
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!editor || saving) {return;}
|
||||
|
||||
setSaving(true);
|
||||
setSaveStatus("idle");
|
||||
|
||||
try {
|
||||
// Serialize editor content back to markdown
|
||||
// The Markdown extension adds getMarkdown() to the editor instance
|
||||
const editorAny = editor as unknown as { getMarkdown?: () => string };
|
||||
let markdown: string;
|
||||
|
||||
if (typeof editorAny.getMarkdown === "function") {
|
||||
markdown = editorAny.getMarkdown();
|
||||
} else {
|
||||
// Fallback: use HTML output
|
||||
markdown = editor.getHTML();
|
||||
}
|
||||
|
||||
// Convert report block HTML back to ```report-json``` fenced blocks
|
||||
const bodyContent = postprocessReportBlocks(markdown);
|
||||
// Prepend preserved frontmatter so it isn't lost on save
|
||||
const finalContent = frontmatterRef.current + bodyContent;
|
||||
|
||||
// Virtual paths (~skills/*, ~memories/*) use the virtual-file API
|
||||
const saveEndpoint = filePath.startsWith("~")
|
||||
? "/api/workspace/virtual-file"
|
||||
: "/api/workspace/file";
|
||||
const res = await fetch(saveEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: filePath, content: finalContent }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setSaveStatus("saved");
|
||||
setIsDirty(false);
|
||||
// Sync the prop tracker to the body we just saved so the external-update
|
||||
// effect doesn't see a mismatch and reset the editor.
|
||||
lastPropContentRef.current = content;
|
||||
onSave?.();
|
||||
|
||||
// Clear "saved" indicator after 2s
|
||||
if (saveTimerRef.current) {clearTimeout(saveTimerRef.current);}
|
||||
saveTimerRef.current = setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
} else {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editor, filePath, saving, onSave]);
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleSave]);
|
||||
|
||||
// Update content when file changes externally (parent re-fetched the file)
|
||||
useEffect(() => {
|
||||
if (!editor || isDirty) {return;}
|
||||
if (content !== lastPropContentRef.current) {
|
||||
lastPropContentRef.current = content;
|
||||
// Also update frontmatter in case the raw content changed
|
||||
frontmatterRef.current = extractFrontmatter(rawContent ?? "");
|
||||
const processed = preprocessReportBlocks(content);
|
||||
editor.commands.setContent(processed, { contentType: "markdown" });
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [content, rawContent, editor, isDirty]);
|
||||
|
||||
if (!editor) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-3 py-4 px-6">
|
||||
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "80%" }} />
|
||||
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "60%" }} />
|
||||
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "70%" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="markdown-editor-container">
|
||||
{/* Sticky top bar: save status + save button */}
|
||||
<div className="editor-top-bar">
|
||||
<div className="editor-top-bar-left">
|
||||
{isDirty && (
|
||||
<span className="editor-save-indicator editor-save-unsaved">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === "saved" && !isDirty && (
|
||||
<span className="editor-save-indicator editor-save-saved">
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<span className="editor-save-indicator editor-save-error">
|
||||
Save failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="editor-top-bar-right">
|
||||
<span className="editor-save-hint">
|
||||
{typeof navigator !== "undefined" && navigator.platform?.includes("Mac") ? "\u2318" : "Ctrl"}+S
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !isDirty}
|
||||
className="editor-save-button"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<EditorToolbar editor={editor} onUploadImages={insertUploadedImages} />
|
||||
|
||||
{/* Bubble menu for text selection */}
|
||||
<BubbleMenu editor={editor}>
|
||||
<div className="bubble-menu">
|
||||
<BubbleButton
|
||||
active={editor.isActive("bold")}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
title="Bold"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</BubbleButton>
|
||||
<BubbleButton
|
||||
active={editor.isActive("italic")}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
title="Italic"
|
||||
>
|
||||
<em>I</em>
|
||||
</BubbleButton>
|
||||
<BubbleButton
|
||||
active={editor.isActive("strike")}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<s>S</s>
|
||||
</BubbleButton>
|
||||
<BubbleButton
|
||||
active={editor.isActive("code")}
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
title="Inline code"
|
||||
>
|
||||
{"<>"}
|
||||
</BubbleButton>
|
||||
<BubbleButton
|
||||
active={editor.isActive("link")}
|
||||
onClick={() => {
|
||||
if (editor.isActive("link")) {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
} else {
|
||||
const url = window.prompt("URL:");
|
||||
if (url) {
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Link"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
</BubbleButton>
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
|
||||
{/* Editor content */}
|
||||
<div className="editor-content-area workspace-prose">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Toolbar ---
|
||||
|
||||
function EditorToolbar({
|
||||
editor,
|
||||
onUploadImages,
|
||||
}: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
onUploadImages?: (files: File[]) => void;
|
||||
}) {
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (!editor) {return null;}
|
||||
|
||||
return (
|
||||
<div className="editor-toolbar">
|
||||
{/* Headings */}
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("heading", { level: 1 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
title="Heading 1"
|
||||
>
|
||||
H1
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("heading", { level: 2 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
title="Heading 2"
|
||||
>
|
||||
H2
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("heading", { level: 3 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
title="Heading 3"
|
||||
>
|
||||
H3
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
|
||||
<ToolbarDivider />
|
||||
|
||||
{/* Inline formatting */}
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("bold")}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
title="Bold"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("italic")}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
title="Italic"
|
||||
>
|
||||
<em>I</em>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("strike")}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<s>S</s>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("code")}
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
title="Inline code"
|
||||
>
|
||||
{"<>"}
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
|
||||
<ToolbarDivider />
|
||||
|
||||
{/* Block elements */}
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("bulletList")}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
title="Bullet list"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="8" x2="21" y1="6" y2="6" /><line x1="8" x2="21" y1="12" y2="12" /><line x1="8" x2="21" y1="18" y2="18" />
|
||||
<line x1="3" x2="3.01" y1="6" y2="6" /><line x1="3" x2="3.01" y1="12" y2="12" /><line x1="3" x2="3.01" y1="18" y2="18" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("orderedList")}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
title="Ordered list"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="10" x2="21" y1="6" y2="6" /><line x1="10" x2="21" y1="12" y2="12" /><line x1="10" x2="21" y1="18" y2="18" />
|
||||
<path d="M4 6h1v4" /><path d="M4 10h2" /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("taskList")}
|
||||
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||
title="Task list"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="5" width="6" height="6" rx="1" /><path d="m3 17 2 2 4-4" /><line x1="13" x2="21" y1="6" y2="6" /><line x1="13" x2="21" y1="18" y2="18" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("blockquote")}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
title="Blockquote"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z" />
|
||||
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3z" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("codeBlock")}
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
title="Code block"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
|
||||
<ToolbarDivider />
|
||||
|
||||
{/* Insert items */}
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
active={false}
|
||||
onClick={() => {
|
||||
const url = window.prompt("Link URL:");
|
||||
if (url) {
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
}
|
||||
}}
|
||||
title="Insert link"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={false}
|
||||
onClick={() => {
|
||||
// Open file picker for local images; shift-click for URL input
|
||||
if (onUploadImages) {
|
||||
imageInputRef.current?.click();
|
||||
} else {
|
||||
const url = window.prompt("Image URL:");
|
||||
if (url) {
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Insert image (click to upload, or drag & drop)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
{/* Hidden file input for image upload */}
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length > 0 && onUploadImages) {
|
||||
onUploadImages(files);
|
||||
}
|
||||
// Reset so the same file can be picked again
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<ToolbarButton
|
||||
active={false}
|
||||
onClick={() => {
|
||||
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
}}
|
||||
title="Insert table"
|
||||
>
|
||||
<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>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={false}
|
||||
onClick={() => {
|
||||
editor.chain().focus().setHorizontalRule().run();
|
||||
}}
|
||||
title="Horizontal rule"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="2" x2="22" y1="12" y2="12" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Toolbar primitives ---
|
||||
|
||||
function ToolbarGroup({ children }: { children: React.ReactNode }) {
|
||||
return <div className="editor-toolbar-group">{children}</div>;
|
||||
}
|
||||
|
||||
function ToolbarDivider() {
|
||||
return <div className="editor-toolbar-divider" />;
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
active,
|
||||
onClick,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`editor-toolbar-btn ${active ? "editor-toolbar-btn-active" : ""}`}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Bubble menu button ---
|
||||
|
||||
function BubbleButton({
|
||||
active,
|
||||
onClick,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`bubble-menu-btn ${active ? "bubble-menu-btn-active" : ""}`}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
387
apps/web/app/components/workspace/media-viewer.tsx
Normal file
387
apps/web/app/components/workspace/media-viewer.tsx
Normal file
@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type MediaType = "image" | "video" | "audio" | "pdf";
|
||||
|
||||
type MediaViewerProps = {
|
||||
/** URL to serve the raw file (e.g. /api/workspace/raw-file?path=...) */
|
||||
url: string;
|
||||
/** Original filename for display */
|
||||
filename: string;
|
||||
/** Detected media type */
|
||||
mediaType: MediaType;
|
||||
/** Original workspace path for download/copy */
|
||||
filePath?: string;
|
||||
};
|
||||
|
||||
// --- Extension → MediaType mapping ---
|
||||
|
||||
const IMAGE_EXTS = new Set([
|
||||
"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif", "heic", "heif",
|
||||
"ico", "tiff", "tif",
|
||||
]);
|
||||
const VIDEO_EXTS = new Set(["mp4", "webm", "mov", "avi", "mkv"]);
|
||||
const AUDIO_EXTS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac"]);
|
||||
const PDF_EXTS = new Set(["pdf"]);
|
||||
|
||||
/** Returns the media type for a filename, or null if it's not a known media file. */
|
||||
export function detectMediaType(filename: string): MediaType | null {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (IMAGE_EXTS.has(ext)) {return "image";}
|
||||
if (VIDEO_EXTS.has(ext)) {return "video";}
|
||||
if (AUDIO_EXTS.has(ext)) {return "audio";}
|
||||
if (PDF_EXTS.has(ext)) {return "pdf";}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function DownloadIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" x2="12" y1="15" y2="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLinkIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomInIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" x2="16.65" y1="21" y2="16.65" />
|
||||
<line x1="11" x2="11" y1="8" y2="14" />
|
||||
<line x1="8" x2="14" y1="11" y2="11" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomOutIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" x2="16.65" y1="21" y2="16.65" />
|
||||
<line x1="8" x2="14" y1="11" y2="11" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function mediaTypeLabel(mediaType: MediaType): string {
|
||||
switch (mediaType) {
|
||||
case "image": return "Image";
|
||||
case "video": return "Video";
|
||||
case "audio": return "Audio";
|
||||
case "pdf": return "PDF";
|
||||
}
|
||||
}
|
||||
|
||||
function mediaTypeColor(mediaType: MediaType): string {
|
||||
switch (mediaType) {
|
||||
case "image": return "#60a5fa";
|
||||
case "video": return "#c084fc";
|
||||
case "audio": return "#f59e0b";
|
||||
case "pdf": return "#ef4444";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export function MediaViewer({ url, filename, mediaType, filePath }: MediaViewerProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-5 py-3 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<MediaTypeIcon mediaType={mediaType} />
|
||||
<span className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: `${mediaTypeColor(mediaType)}18`,
|
||||
color: mediaTypeColor(mediaType),
|
||||
border: `1px solid ${mediaTypeColor(mediaType)}30`,
|
||||
}}
|
||||
>
|
||||
{mediaTypeLabel(mediaType)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
{/* Open in new tab */}
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 rounded-md transition-colors duration-100"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Open in new tab"
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
</a>
|
||||
{/* Download */}
|
||||
<a
|
||||
href={url}
|
||||
download={filename}
|
||||
className="p-1.5 rounded-md transition-colors duration-100"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Download"
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto flex items-center justify-center p-6" style={{ background: "var(--color-surface)" }}>
|
||||
{mediaType === "image" && <ImageViewer url={url} filename={filename} />}
|
||||
{mediaType === "video" && <VideoViewer url={url} />}
|
||||
{mediaType === "audio" && <AudioViewer url={url} filename={filename} />}
|
||||
{mediaType === "pdf" && <PdfViewer url={url} />}
|
||||
</div>
|
||||
|
||||
{/* Footer with path */}
|
||||
{filePath && (
|
||||
<div
|
||||
className="px-5 py-2 border-t flex-shrink-0 flex items-center"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span
|
||||
className="text-[11px] truncate"
|
||||
style={{ color: "var(--color-text-muted)", fontFamily: "'SF Mono', 'Fira Code', monospace" }}
|
||||
>
|
||||
{filePath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Image Viewer (with zoom) ---
|
||||
|
||||
function ImageViewer({ url, filename }: { url: string; filename: string }) {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const handleZoomIn = useCallback(() => setZoom((z) => Math.min(z * 1.5, 5)), []);
|
||||
const handleZoomOut = useCallback(() => setZoom((z) => Math.max(z / 1.5, 0.25)), []);
|
||||
const handleReset = useCallback(() => setZoom(1), []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-12">
|
||||
<span className="text-4xl" style={{ opacity: 0.3 }}>🖼</span>
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Failed to load image
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleZoomOut}
|
||||
className="p-1.5 rounded-md transition-colors duration-100 cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Zoom out"
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
<ZoomOutIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="px-2 py-1 rounded-md text-[11px] tabular-nums transition-colors duration-100 cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Reset zoom"
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleZoomIn}
|
||||
className="p-1.5 rounded-md transition-colors duration-100 cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Zoom in"
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Image container with checkerboard background for transparency */}
|
||||
<div
|
||||
className="overflow-auto max-w-full max-h-[calc(100vh-260px)] rounded-xl border"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
backgroundImage: "linear-gradient(45deg, var(--color-surface-hover) 25%, transparent 25%), linear-gradient(-45deg, var(--color-surface-hover) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--color-surface-hover) 75%), linear-gradient(-45deg, transparent 75%, var(--color-surface-hover) 75%)",
|
||||
backgroundSize: "20px 20px",
|
||||
backgroundPosition: "0 0, 0 10px, 10px -10px, -10px 0px",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
alt={filename}
|
||||
onError={() => setError(true)}
|
||||
style={{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: "center center",
|
||||
transition: "transform 200ms ease",
|
||||
maxWidth: zoom <= 1 ? "100%" : "none",
|
||||
display: "block",
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Video Viewer ---
|
||||
|
||||
function VideoViewer({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="w-full max-w-4xl">
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
className="w-full rounded-xl border"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
maxHeight: "calc(100vh - 220px)",
|
||||
background: "#000",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Audio Viewer ---
|
||||
|
||||
function AudioViewer({ url, filename }: { url: string; filename: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 py-8">
|
||||
{/* Visual representation */}
|
||||
<div
|
||||
className="w-32 h-32 rounded-2xl flex items-center justify-center"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #f59e0b20, #f59e0b10)",
|
||||
border: "1px solid #f59e0b30",
|
||||
}}
|
||||
>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</p>
|
||||
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<audio src={url} controls className="w-full max-w-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- PDF Viewer ---
|
||||
|
||||
function PdfViewer({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<iframe
|
||||
src={url}
|
||||
className="w-full flex-1 rounded-xl border"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
minHeight: "calc(100vh - 220px)",
|
||||
background: "white",
|
||||
}}
|
||||
title="PDF viewer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Media type icon ---
|
||||
|
||||
function MediaTypeIcon({ mediaType }: { mediaType: MediaType }) {
|
||||
const color = mediaTypeColor(mediaType);
|
||||
|
||||
switch (mediaType) {
|
||||
case "image":
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
|
||||
<rect x="2" y="6" width="14" height="12" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
case "pdf":
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
383
apps/web/app/components/workspace/object-kanban.tsx
Normal file
383
apps/web/app/components/workspace/object-kanban.tsx
Normal file
@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
type Field = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
enum_values?: string[];
|
||||
enum_colors?: string[];
|
||||
related_object_name?: string;
|
||||
};
|
||||
|
||||
type Status = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
sort_order?: number;
|
||||
};
|
||||
|
||||
type ObjectKanbanProps = {
|
||||
objectName: string;
|
||||
fields: Field[];
|
||||
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
|
||||
.filter(
|
||||
(f) =>
|
||||
f.type !== "richtext" &&
|
||||
entry[f.name] !== null &&
|
||||
entry[f.name] !== undefined &&
|
||||
entry[f.name] !== "",
|
||||
)
|
||||
.slice(0, 4);
|
||||
|
||||
// Find a "name" or "title" field for the card header
|
||||
const titleField = fields.find(
|
||||
(f) =>
|
||||
f.name.toLowerCase().includes("name") ||
|
||||
f.name.toLowerCase().includes("title"),
|
||||
);
|
||||
const title = titleField
|
||||
? String(entry[titleField.name] ?? "Untitled")
|
||||
: String(entry[fields[0]?.name] ?? "Untitled");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-3 mb-2 transition-all duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor =
|
||||
"var(--color-text-muted)";
|
||||
(e.currentTarget as HTMLElement).style.transform = "translateY(-1px)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor =
|
||||
"var(--color-border)";
|
||||
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-medium mb-1.5 truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{displayFields
|
||||
.filter((f) => f !== titleField)
|
||||
.slice(0, 3)
|
||||
.map((field) => {
|
||||
const val = entry[field.name];
|
||||
if (!val) {return null;}
|
||||
|
||||
// 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 (
|
||||
<div key={field.id} className="flex items-center gap-1.5 text-xs">
|
||||
<span style={{ color: "var(--color-text-muted)" }}>
|
||||
{field.name}:
|
||||
</span>
|
||||
{field.type === "enum" ? (
|
||||
<EnumBadgeMini
|
||||
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"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{displayVal}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnumBadgeMini({
|
||||
value,
|
||||
enumValues,
|
||||
enumColors,
|
||||
}: {
|
||||
value: string;
|
||||
enumValues?: string[];
|
||||
enumColors?: string[];
|
||||
}) {
|
||||
const idx = enumValues?.indexOf(value) ?? -1;
|
||||
const color = idx >= 0 && enumColors ? enumColors[idx] : "#94a3b8";
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0 rounded text-[11px] font-medium"
|
||||
style={{
|
||||
background: `${color}20`,
|
||||
color: color,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Kanban Board ---
|
||||
|
||||
export function ObjectKanban({
|
||||
objectName,
|
||||
fields,
|
||||
entries,
|
||||
statuses,
|
||||
members,
|
||||
relationLabels,
|
||||
}: ObjectKanbanProps) {
|
||||
// Find the grouping field: prefer a "Status" enum field, fallback to first enum
|
||||
const groupField = useMemo(() => {
|
||||
const statusField = fields.find(
|
||||
(f) =>
|
||||
f.type === "enum" &&
|
||||
f.name.toLowerCase().includes("status"),
|
||||
);
|
||||
if (statusField) {return statusField;}
|
||||
return fields.find((f) => f.type === "enum") ?? null;
|
||||
}, [fields]);
|
||||
|
||||
// Determine columns: from statuses table, or from enum_values, or from unique values
|
||||
const columns = useMemo(() => {
|
||||
if (statuses.length > 0) {
|
||||
return statuses.map((s) => ({
|
||||
name: s.name,
|
||||
color: s.color ?? "#94a3b8",
|
||||
}));
|
||||
}
|
||||
if (groupField?.enum_values) {
|
||||
return groupField.enum_values.map((v, i) => ({
|
||||
name: v,
|
||||
color: groupField.enum_colors?.[i] ?? "#94a3b8",
|
||||
}));
|
||||
}
|
||||
// Fallback: derive from data
|
||||
const unique = new Set<string>();
|
||||
for (const e of entries) {
|
||||
const val = groupField ? e[groupField.name] : undefined;
|
||||
if (val) {unique.add(String(val));}
|
||||
}
|
||||
return Array.from(unique).map((v) => ({ name: v, color: "#94a3b8" }));
|
||||
}, [statuses, groupField, entries]);
|
||||
|
||||
// Group entries by column
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, Record<string, unknown>[]> = {};
|
||||
for (const col of columns) {groups[col.name] = [];}
|
||||
|
||||
// Ungrouped bucket
|
||||
groups["_ungrouped"] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const val = groupField ? String(entry[groupField.name] ?? "") : "";
|
||||
if (groups[val]) {
|
||||
groups[val].push(entry);
|
||||
} else {
|
||||
groups["_ungrouped"].push(entry);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [columns, entries, groupField]);
|
||||
|
||||
// Non-grouping fields for cards
|
||||
const cardFields = fields.filter((f) => f !== groupField);
|
||||
|
||||
if (!groupField) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
No enum field found for kanban grouping in{" "}
|
||||
<span className="font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{objectName}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 px-1" style={{ minHeight: "400px" }}>
|
||||
{columns.map((col) => {
|
||||
const items = grouped[col.name] ?? [];
|
||||
return (
|
||||
<div
|
||||
key={col.name}
|
||||
className="flex-shrink-0 flex flex-col rounded-xl"
|
||||
style={{
|
||||
width: "280px",
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ background: col.color }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-medium flex-1"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{col.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
className="flex items-center justify-center py-8 rounded-lg border border-dashed text-xs"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
No entries
|
||||
</div>
|
||||
) : (
|
||||
items.map((entry, idx) => (
|
||||
<KanbanCard
|
||||
key={String(entry.entry_id ?? idx)}
|
||||
entry={entry}
|
||||
fields={cardFields}
|
||||
members={members}
|
||||
relationLabels={relationLabels}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ungrouped entries */}
|
||||
{grouped["_ungrouped"]?.length > 0 && (
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col rounded-xl"
|
||||
style={{
|
||||
width: "280px",
|
||||
background: "var(--color-bg)",
|
||||
border: "1px dashed var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||
Ungrouped
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{grouped["_ungrouped"].length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{grouped["_ungrouped"].map((entry, idx) => (
|
||||
<KanbanCard
|
||||
key={String(entry.entry_id ?? idx)}
|
||||
entry={entry}
|
||||
fields={cardFields}
|
||||
members={members}
|
||||
relationLabels={relationLabels}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user