From afb25db484151e1750542741d6b8652fcda948d2 Mon Sep 17 00:00:00 2001 From: Rowan Trollope Date: Tue, 5 May 2026 22:13:02 -0700 Subject: [PATCH 1/6] Pre-review cleanup pass - Doc/config paper cuts: drop retired Redis-module refs from .dockerignore/.gitignore, drop phantom afs-server target from Makefile, align example.afs.config.json with README shape, archive completed redis-array-backend plan, move deploy/vercel/auth-plan.md and onboarding-flows.md into plans/, convert absolute /Users/... paths to relative, dedup duplicated Phase 4/5 headings in cli-first-ui.md, refresh repo-walkthrough. - Dead code (-480 LOC): delete afs-situation-room-kit.tsx (zero importers), no-op BackgroundPatternProvider, six exported trampoline helpers in file_versions.go, dead parentPath/baseName in mount/internal/afsfs/fs.go, unused Capabilities type alias, codex-settings-migration.md (superseded by skill). - UI deps: convert static jszip import to dynamic await import so it ships as its own ~96 KB chunk on user action; migrate lucide-icons.tsx to the canonical @redis-ui/styles name; clear 17 auto-fixable lint errors (baseline 44 -> 27). - UI helpers: add foundation/sort-compare.ts (replaces six compareValues copies) and foundation/clipboard-icons.tsx (replaces two CopyIcon/CheckIcon pairs). - Renames: cmd/afs/sync_reconcile.go -> sync_full_reconciler.go and sync_reconciler.go -> sync_event_reconciler.go (plus its test) so the two halves of the sync pipeline are no longer confusable neighbors. - CI: add .github/workflows/ci.yml with parallel jobs for the root Go module, mount module (with redis-server), sandbox module, UI build/test, and a non-blocking ui-lint job tracking the 27 baseline lint errors. - Sandbox hardening: default --bind to 127.0.0.1, add a threat-model docstring at the top of cmd/sandbox/main.go, and log a WARNING at startup if bound externally. - FUSE correctness fix: mount/internal/afsfs/{handle,file}.go now route writes and truncates through WriteInodeAtPath / TruncateInodeAtPath. The legacy no-path entry points wiped the entire client attribute cache on every FUSE write via finishRangeWriteCache's invalidatePrefix("/"). NFS already used the *AtPath variants and was unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 7 +- .github/workflows/ci.yml | 103 ++++++ .gitignore | 2 - Makefile | 2 +- ...reconciler.go => sync_event_reconciler.go} | 0 ..._test.go => sync_event_reconciler_test.go} | 0 ...c_reconcile.go => sync_full_reconciler.go} | 0 deploy/vercel/README.md | 16 +- docs/internals/repo-walkthrough.md | 18 +- example.afs.config.json | 23 +- examples/codex-settings-migration.md | 142 --------- internal/controlplane/file_versions.go | 24 -- internal/controlplane/service.go | 1 - mount/internal/afsfs/file.go | 8 +- mount/internal/afsfs/fs.go | 21 -- mount/internal/afsfs/handle.go | 5 +- .../2026-05-05-redis-array-backend.md} | 3 +- plans/cli-first-ui.md | 13 +- .../auth-plan.md => plans/cloud-auth.md | 0 .../cloud-onboarding.md | 5 +- sandbox/cmd/sandbox/main.go | 30 +- ui/package-lock.json | 40 +-- ui/src/components/afs-situation-room-kit.tsx | 297 ------------------ ui/src/components/lucide-icons.tsx | 2 +- .../agent-experience/SiteModeFrame.tsx | 3 +- .../public-agent-documents.ts | 3 +- .../features/agents/CreateMCPAccessDialog.tsx | 7 +- .../features/agents/LocalMCPAccessDialog.tsx | 2 +- .../templates/TemplateInstallDetail.tsx | 2 +- .../workspaces/CreateWorkspaceDialog.tsx | 26 +- ui/src/foundation/background-pattern.tsx | 5 - ui/src/foundation/clipboard-icons.tsx | 40 +++ ui/src/foundation/sort-compare.ts | 13 + .../foundation/tables/access-tokens-table.tsx | 19 +- ui/src/foundation/tables/activity-table.tsx | 14 +- ui/src/foundation/tables/changes-table.tsx | 13 +- ui/src/foundation/tables/database-table.tsx | 38 +-- ui/src/foundation/tables/events-table.tsx | 14 +- ui/src/foundation/tables/workspace-table.tsx | 52 +-- ui/src/routes/__root.tsx | 13 +- ui/src/routes/mcp.tsx | 3 +- .../workspace-studio/-checkpoints-tab.tsx | 14 +- 42 files changed, 304 insertions(+), 739 deletions(-) create mode 100644 .github/workflows/ci.yml rename cmd/afs/{sync_reconciler.go => sync_event_reconciler.go} (100%) rename cmd/afs/{sync_reconciler_test.go => sync_event_reconciler_test.go} (100%) rename cmd/afs/{sync_reconcile.go => sync_full_reconciler.go} (100%) delete mode 100644 examples/codex-settings-migration.md rename plans/{redis-array-backend.md => archive/2026-05-05-redis-array-backend.md} (99%) rename deploy/vercel/auth-plan.md => plans/cloud-auth.md (100%) rename deploy/vercel/onboarding-flows.md => plans/cloud-onboarding.md (95%) delete mode 100644 ui/src/components/afs-situation-room-kit.tsx delete mode 100644 ui/src/foundation/background-pattern.tsx create mode 100644 ui/src/foundation/clipboard-icons.tsx create mode 100644 ui/src/foundation/sort-compare.ts diff --git a/.dockerignore b/.dockerignore index ffa6d51..54326d7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,10 +10,6 @@ __pycache__/ # Local build artifacts /afs -/afs-server -/redis-qmd -/module/*.so -/module/*.xo /mount/agent-filesystem-mount /mount/agent-filesystem-nfs /sandbox/sandbox @@ -33,6 +29,5 @@ __pycache__/ afs.config.json afs.databases.json -# Retired root note/artifact folders +# Retired root note folder; plans/ stays in the build context. /tasks -/plans diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d004da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,103 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + go-root: + name: Go (root) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: "1.22" + cache-dependency-path: go.sum + - name: Vet + run: go vet ./cmd/... ./internal/... ./deploy/... + - name: Test + run: go test ./cmd/... ./internal/... ./deploy/... + + go-mount: + name: Go (mount) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + # Mount client tests fork a real redis-server per test (see + # mount/internal/client/native_test.go); install the binary so the + # tests can spawn it. + - name: Install redis-server + run: sudo apt-get update && sudo apt-get install -y redis-server + - uses: actions/setup-go@v6 + with: + go-version: "1.22" + cache-dependency-path: mount/go.sum + - name: Vet + working-directory: mount + run: go vet ./... + - name: Test + working-directory: mount + run: go test ./... + + go-sandbox: + name: Go (sandbox) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: "1.22" + cache-dependency-path: sandbox/go.sum + - name: Vet + working-directory: sandbox + run: go vet ./... + - name: Test + working-directory: sandbox + run: go test ./... + + ui: + name: UI + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + cache-dependency-path: ui/package-lock.json + - run: npm ci + - name: Build + run: npm run build + - name: Test + run: npm test + + ui-lint: + name: UI lint + runs-on: ubuntu-latest + # The lint job is intentionally non-blocking until the 27 baseline + # @typescript-eslint/no-unnecessary-condition errors are addressed. + # Drop continue-on-error once the baseline is clean. + continue-on-error: true + defaults: + run: + working-directory: ui + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + cache-dependency-path: ui/package-lock.json + - run: npm ci + - name: Lint + run: npm run lint diff --git a/.gitignore b/.gitignore index b14b28f..957c336 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ *.dSYM/ /afs /afs-control-plane -/afs-server mount/agent-filesystem-mount mount/agent-filesystem-nfs sandbox/sandbox @@ -11,7 +10,6 @@ afs.config.json afs.config.json.backup afs.databases.json afs.catalog.sqlite* -module/ ui/.tanstack/ .claude/ diff --git a/Makefile b/Makefile index 06bb3b0..fb76b5d 100644 --- a/Makefile +++ b/Makefile @@ -88,7 +88,7 @@ uninstall: ## Remove the installed afs symlink from $(BINDIR). clean: ## Remove compiled artifacts. $(MAKE) -C mount clean - $(RM) afs afs-control-plane afs-server + $(RM) afs afs-control-plane test: ## Run Go unit tests for the active product surfaces. go test ./cmd/... ./deploy/... ./internal/... diff --git a/cmd/afs/sync_reconciler.go b/cmd/afs/sync_event_reconciler.go similarity index 100% rename from cmd/afs/sync_reconciler.go rename to cmd/afs/sync_event_reconciler.go diff --git a/cmd/afs/sync_reconciler_test.go b/cmd/afs/sync_event_reconciler_test.go similarity index 100% rename from cmd/afs/sync_reconciler_test.go rename to cmd/afs/sync_event_reconciler_test.go diff --git a/cmd/afs/sync_reconcile.go b/cmd/afs/sync_full_reconciler.go similarity index 100% rename from cmd/afs/sync_reconcile.go rename to cmd/afs/sync_full_reconciler.go diff --git a/deploy/vercel/README.md b/deploy/vercel/README.md index 9472f72..d6c5a75 100644 --- a/deploy/vercel/README.md +++ b/deploy/vercel/README.md @@ -20,14 +20,14 @@ What does not belong here: Current docs: -- [deployment-shape.md](/Users/rowantrollope/git/agent-filesystem/deploy/vercel/deployment-shape.md) -- [onboarding-flows.md](/Users/rowantrollope/git/agent-filesystem/deploy/vercel/onboarding-flows.md) -- [auth-plan.md](/Users/rowantrollope/git/agent-filesystem/deploy/vercel/auth-plan.md) +- [deployment-shape.md](deployment-shape.md) +- Hosted onboarding plan: [../../plans/cloud-onboarding.md](../../plans/cloud-onboarding.md) +- Hosted auth plan: [../../plans/cloud-auth.md](../../plans/cloud-auth.md) Current wrapper: -- [main.go](/Users/rowantrollope/git/agent-filesystem/deploy/vercel/main.go) is the thin Vercel-specific control-plane entrypoint used for preview boot/build validation. -- [preview.sh](/Users/rowantrollope/git/agent-filesystem/deploy/vercel/preview.sh) stages a temporary Vercel build root and deploys a preview with the repo-root Go module intact. +- [main.go](main.go) is the thin Vercel-specific control-plane entrypoint used for preview boot/build validation. +- [preview.sh](preview.sh) stages a temporary Vercel build root and deploys a preview with the repo-root Go module intact. Preview workflow: @@ -47,7 +47,7 @@ Production workflow: Notes: - The script intentionally uses `npx --yes vercel@latest` so it does not collide with any local binary named `vercel`. -- If [deploy/vercel/.vercel/project.json](/Users/rowantrollope/git/agent-filesystem/deploy/vercel/.vercel/project.json) exists locally, the script copies that link metadata into the temporary staging directory before deploy. +- If `.vercel/project.json` exists locally under this directory, the script copies that link metadata into the temporary staging directory before deploy. - If the project is not linked yet, pass `--scope --project ` and the script will link the staging directory before deploying. -- [smoke.sh](/Users/rowantrollope/git/agent-filesystem/deploy/vercel/smoke.sh) uses `vercel curl` so it can hit protected preview deployments without needing a public share link. -- [prod.sh](/Users/rowantrollope/git/agent-filesystem/deploy/vercel/prod.sh) deploys the same staged build root to production and can optionally try to attach a production alias. +- [smoke.sh](smoke.sh) uses `vercel curl` so it can hit protected preview deployments without needing a public share link. +- [prod.sh](prod.sh) deploys the same staged build root to production and can optionally try to attach a production alias. diff --git a/docs/internals/repo-walkthrough.md b/docs/internals/repo-walkthrough.md index 1603b5c..3b54ea6 100644 --- a/docs/internals/repo-walkthrough.md +++ b/docs/internals/repo-walkthrough.md @@ -1,6 +1,6 @@ # Agent Filesystem Repo Walkthrough -This guide is the "what lives where" map for the current `/Users/rowantrollope/git/agent-filesystem` working tree as of 2026-04-24. +This guide is the "what lives where" map for the current `agent-filesystem` working tree as of 2026-05-05. ## Scope @@ -72,6 +72,22 @@ Supporting areas: - Manifest scanning, blob collection, and local materialization helpers. +### `internal/rediscontent/` + +- Shared Redis content backend helpers: Array capability detection, chunked array IO, and `ARGREP` literal prefiltering used by mount and control-plane file-content paths. + +### `internal/searchindex/` + +- RediSearch index helpers used by grep and workspace catalog. + +### `internal/mcpproto/` + +- Shared MCP protocol types used by both the CLI and hosted MCP surfaces. + +### `internal/version/` + +- Build-time version metadata injected via `-ldflags` from the Makefile. + ### `mount/` - The Redis-backed filesystem client plus the FUSE and NFS daemons. diff --git a/example.afs.config.json b/example.afs.config.json index c8005a1..d8892bb 100644 --- a/example.afs.config.json +++ b/example.afs.config.json @@ -6,8 +6,23 @@ "db": 0, "tls": false }, - "controlPlane": {}, - "agent": {}, - "productMode": "local", - "mode": "sync" + "mode": "sync", + "currentWorkspace": "", + "localPath": "~/afs", + "mount": { + "backend": "none", + "readOnly": false, + "allowOther": false, + "mountBin": "", + "nfsBin": "", + "nfsHost": "127.0.0.1", + "nfsPort": 20490 + }, + "logs": { + "mount": "/tmp/afs-mount.log", + "sync": "/tmp/afs-sync.log" + }, + "sync": { + "fileSizeCapMB": 2048 + } } diff --git a/examples/codex-settings-migration.md b/examples/codex-settings-migration.md deleted file mode 100644 index cabf39b..0000000 --- a/examples/codex-settings-migration.md +++ /dev/null @@ -1,142 +0,0 @@ -# Share Codex State Across Computers with Agent Filesystem - -This guide shows how to put `~/.codex` into Agent Filesystem on one computer, then mount that same shared state on other computers so Codex keeps the same memory and settings everywhere. - -Use this when: - -- Codex stores local state in `~/.codex` -- you want the same state across multiple machines -- you usually use one machine at a time and want to resume cleanly when you switch - -## Recommended exclusions - -Before importing, create `~/.codex/.afsignore` to exclude machine-local or high-churn state you do not want to sync. - -Suggested starting point: - -```gitignore -# High-churn caches -cache/ -tmp/ - -# Local checkout state -worktrees/ - -# Local logs and temp files -logs/ -*.log -*.tmp -*.pid -*.sock -``` - -`worktrees/` is a good default exclusion. It is usually large, machine-local, and likely to cause confusion if multiple computers treat it as shared state. - -Because `.afsignore` uses `.gitignore`-style rules, you can also re-include a specific file with `!`, for example: - -```gitignore -*.log -!logs/important.log -``` - -## Machine 1: import the existing `~/.codex` - -Build Agent Filesystem: - -```bash -cd /path/to/agent-filesystem -make -``` - -Connect AFS to your shared Redis instance. - -Important setup choices: - -- choose your shared Redis host, password, and DB -- choose workspace name `.codex` -- choose mountpoint `~/.codex` - -Create or review the ignore file: - -```bash -cat > ~/.codex/.afsignore <<'EOF' -cache/ -tmp/ -worktrees/ -logs/ -*.log -*.tmp -*.pid -*.sock -EOF -``` - -Import the existing directory into the `.codex` workspace and mount it in place: - -```bash -./afs ws import --mount-at-source .codex ~/.codex -``` - -What that does: - -- imports `~/.codex` into the workspace `.codex` -- mounts the imported workspace at `~/.codex` - -Verify: - -```bash -./afs status -ls -la ~/.codex -``` - -## Machine 2 and later: mount the same shared Codex state - -On each additional computer: - -1. Build `agent-filesystem`. -2. Connect it to the same control plane or Redis instance. -3. Choose sync or mount mode. -4. Attach workspace `.codex` at `~/.codex`. - -Back up any existing local Codex directory first: - -```bash -if [ -d ~/.codex ]; then mv ~/.codex ~/.codex.local-backup; fi -mkdir -p ~/.codex -``` - -Then mount the shared workspace: - -```bash -./afs ws mount .codex ~/.codex -./afs status -ls -la ~/.codex -``` - -## Agent checklist - -If you want an agent to perform this, the agent should: - -1. Confirm Codex is not currently running on the machines involved. -2. Recommend creating `~/.codex/.afsignore` before import. -3. Suggest excluding `worktrees/` by default, unless the user explicitly wants local checkout state shared. -4. Build `agent-filesystem` with `make`. -5. On the first machine, connect AFS, then run `./afs ws import --mount-at-source .codex ~/.codex`. -6. On later machines, back up any existing `~/.codex`, connect AFS, then run `./afs ws mount .codex ~/.codex`. -7. Verify that the same Codex files appear on every machine. - -## Rollback - -Undo on the first computer: - -```bash -./afs ws unmount ~/.codex -``` - -Undo on a later computer: - -```bash -./afs ws unmount ~/.codex -rm -rf ~/.codex -mv ~/.codex.local-backup ~/.codex -``` diff --git a/internal/controlplane/file_versions.go b/internal/controlplane/file_versions.go index 0058c23..7558e87 100644 --- a/internal/controlplane/file_versions.go +++ b/internal/controlplane/file_versions.go @@ -1063,30 +1063,6 @@ func (s *Store) getFileLineageForCmd(ctx context.Context, cmd redis.Cmdable, sto return getJSON[FileLineage](ctx, cmd, fileLineageMetaKey(storageID, strings.TrimSpace(fileID))) } -func WorkspacePathFileIDsKey(workspace string) string { - return workspacePathFileIDsKey(workspace) -} - -func WorkspacePathHistoryKey(workspace, normalizedPath string) string { - return workspacePathHistoryKey(workspace, normalizedPath) -} - -func WorkspaceVersionFileIDsKey(workspace string) string { - return workspaceVersionFileIDsKey(workspace) -} - -func FileLineageMetaKey(workspace, fileID string) string { - return fileLineageMetaKey(workspace, fileID) -} - -func FileLineageVersionsKey(workspace, fileID string) string { - return fileLineageVersionsKey(workspace, fileID) -} - -func FileVersionKey(workspace, fileID, versionID string) string { - return fileVersionKey(workspace, fileID, versionID) -} - func workspacePathFileIDsKey(workspace string) string { return fmt.Sprintf("afs:{%s}:workspace:path_file_ids", workspace) } diff --git a/internal/controlplane/service.go b/internal/controlplane/service.go index 24445d8..e52732a 100644 --- a/internal/controlplane/service.go +++ b/internal/controlplane/service.go @@ -375,7 +375,6 @@ type Service struct { catalogDatabaseName string } -type Capabilities = capabilities type WorkspaceSummary = workspaceSummary type WorkspaceListResponse = workspaceListResponse type CheckpointSummary = checkpointSummary diff --git a/mount/internal/afsfs/file.go b/mount/internal/afsfs/file.go index db754c1..176830c 100644 --- a/mount/internal/afsfs/file.go +++ b/mount/internal/afsfs/file.go @@ -32,7 +32,9 @@ func (n *FSNode) Create(ctx context.Context, name string, flags uint32, mode uin handle := newFileHandle(child.fsPath, st.Inode, n.client, child) if flags&syscall.O_TRUNC != 0 { - if err := n.client.TruncateInode(ctx, st.Inode, 0); err != nil { + // *AtPath updates the path cache in place; the no-path variant flushes + // the whole cache. + if err := n.client.TruncateInodeAtPath(ctx, st.Inode, child.fsPath, 0); err != nil { return nil, nil, 0, mapError(err) } n.root().invalidatePath(child.fsPath) @@ -58,7 +60,9 @@ func (n *FSNode) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, handle := newFileHandle(n.fsPath, st.Inode, n.client, n) if flags&syscall.O_TRUNC != 0 { - if err := n.client.TruncateInode(ctx, st.Inode, 0); err != nil { + // *AtPath updates the path cache in place; the no-path variant flushes + // the whole cache. + if err := n.client.TruncateInodeAtPath(ctx, st.Inode, n.fsPath, 0); err != nil { return nil, 0, mapError(err) } n.root().invalidatePath(n.fsPath) diff --git a/mount/internal/afsfs/fs.go b/mount/internal/afsfs/fs.go index b3e5855..25d4d24 100644 --- a/mount/internal/afsfs/fs.go +++ b/mount/internal/afsfs/fs.go @@ -389,27 +389,6 @@ func GetOwnership() (uint32, uint32) { return uint32(os.Getuid()), uint32(os.Getgid()) } -// parentPath returns the parent dir of a path. -func parentPath(p string) string { - if p == "/" { - return "/" - } - parent := filepath.Dir(p) - if parent == "." { - return "/" - } - return parent -} - -// baseName returns the last component of a path. -func baseName(p string) string { - if p == "/" { - return "" - } - parts := strings.Split(p, "/") - return parts[len(parts)-1] -} - // Ensure interfaces are satisfied. var _ fs.NodeStatfser = (*FSNode)(nil) var _ fs.NodeGetattrer = (*FSNode)(nil) diff --git a/mount/internal/afsfs/handle.go b/mount/internal/afsfs/handle.go index c31ad58..96ff5a6 100644 --- a/mount/internal/afsfs/handle.go +++ b/mount/internal/afsfs/handle.go @@ -51,7 +51,10 @@ func (fh *FileHandle) Write(ctx context.Context, data []byte, off int64) (uint32 fh.mu.Lock() defer fh.mu.Unlock() - if err := fh.client.WriteInodeAt(ctx, fh.inode, data, off); err != nil { + // Use *AtPath so the per-path attribute cache is updated in place rather + // than wiped. Without this, every FUSE write triggered a whole-cache + // invalidatePrefix("/") inside finishRangeWriteCache. + if err := fh.client.WriteInodeAtPath(ctx, fh.inode, fh.path, data, off); err != nil { return 0, mapError(err) } fh.node.root().invalidatePath(fh.path) diff --git a/plans/redis-array-backend.md b/plans/archive/2026-05-05-redis-array-backend.md similarity index 99% rename from plans/redis-array-backend.md rename to plans/archive/2026-05-05-redis-array-backend.md index b444749..93824ce 100644 --- a/plans/redis-array-backend.md +++ b/plans/archive/2026-05-05-redis-array-backend.md @@ -1,9 +1,10 @@ # Redis Array Backend -Status: active +Status: archived Owner: codex Created: 2026-05-05 Updated: 2026-05-05 +Archived: 2026-05-05 ## Goal diff --git a/plans/cli-first-ui.md b/plans/cli-first-ui.md index 825acbd..744c5a7 100644 --- a/plans/cli-first-ui.md +++ b/plans/cli-first-ui.md @@ -69,18 +69,6 @@ These ship into `internal/controlplane/` independently. The inspector tolerates - [ ] SSE on `/v1/activity` and `/v1/sessions`. ~3 days each. - [ ] `POST /v1/auth/exchange` for same-origin Clerk → token bridge. ~2 days. -### Phase 4 — Embedded Console (abandoned) - -Tried to ship a Redis-Insight-style embedded terminal on the Inspector page (built and removed 2026-05-02). Strict-CLI-only commands, ghost-text autocomplete, plain-text output. Output was technically correct but the result didn't earn its place — felt like a half-shell rather than the protagonist. Removed in full. - -If we revisit: rather than partial-shell-in-the-browser, lean into the real CLI from a dedicated route (e.g. an inline-rendered `xterm.js` connected to a websocket-backed PTY running on the control plane in a sandbox). That would be a real terminal, not an emulation. Not on the current branch. - -### Phase 5 — Backend contracts (parallel, 2–3 weeks) - -The contracts surfaced by `agent-site/` need to land in `internal/controlplane/`. These are independent of the UI repositioning but make the inspector + console more useful as they ship: - -- Receipts on changelog stream, `/why/:action_id`, ETag/If-Match middleware, X-AFS-Cost middleware, X-AFS-DryRun, SSE on `/v1/activity`, `POST /v1/auth/exchange`. (See earlier plan for details.) - ### Phase 6 — Cutover (3–4 days) - [ ] Final demo of new `ui/` vs old `ui/` (worktree) — confirm CLI-first feel @@ -95,6 +83,7 @@ The contracts surfaced by `agent-site/` need to land in `internal/controlplane/` ## Decisions / Blockers +- **Embedded Console attempt — abandoned 2026-05-02.** Tried a Redis-Insight-style embedded terminal on the Inspector page: strict-CLI-only commands, ghost-text autocomplete, plain-text output. Output was technically correct but the result didn't earn its place — felt like a half-shell rather than the protagonist. Removed in full. If we revisit, lean into the real CLI from a dedicated route (e.g. an inline-rendered `xterm.js` connected to a websocket-backed PTY running on the control plane in a sandbox) — a real terminal, not an emulation. Not on the current branch. - **Branch over parallel directory** — decided 2026-05-01. Reasons: avoid duplicated Clerk/router/query plumbing; avoid maintaining 3 UIs during transition; PRs review as refactor diffs; cutover is just `git merge` not a directory rename. Side-by-side need is met by `git worktree add ../afs-main main`. - **Inspector path** — leaning `/inspect` over `/dashboard` or `/`. Verb-y, observability-honest. Decide before Phase 4. - **First-run experience** — leaning toward "your CLI hasn't done anything yet" hero (no auto-provisioned starter workspace) to reinforce CLI-first story on day one. Open question. diff --git a/deploy/vercel/auth-plan.md b/plans/cloud-auth.md similarity index 100% rename from deploy/vercel/auth-plan.md rename to plans/cloud-auth.md diff --git a/deploy/vercel/onboarding-flows.md b/plans/cloud-onboarding.md similarity index 95% rename from deploy/vercel/onboarding-flows.md rename to plans/cloud-onboarding.md index 422f64d..d0df082 100644 --- a/deploy/vercel/onboarding-flows.md +++ b/plans/cloud-onboarding.md @@ -21,7 +21,7 @@ Both ramps should converge on the same steady state: As of 2026-04-17, the hosted production deploy now has these pieces in place: -- Vercel preview deploys boot from [main.go](/Users/rowantrollope/git/agent-filesystem/deploy/vercel/main.go) +- Vercel preview deploys boot from [main.go](../deploy/vercel/main.go) - control-plane catalog uses Neon/Postgres in hosted mode - workspace data plane uses Redis Cloud via `REDIS_URL` - first-run hosted bootstrap auto-seeds a managed database profile when the @@ -161,8 +161,7 @@ The next major milestone is: 4. workspace ownership and authorization checks 5. browser-authenticated CLI handoff -See [auth-plan.md](/Users/rowantrollope/git/agent-filesystem/deploy/vercel/auth-plan.md) -for the recommended hosted auth direction. +See [cloud-auth.md](cloud-auth.md) for the recommended hosted auth direction. Important deployment rule: diff --git a/sandbox/cmd/sandbox/main.go b/sandbox/cmd/sandbox/main.go index 5d389a2..9bf8378 100644 --- a/sandbox/cmd/sandbox/main.go +++ b/sandbox/cmd/sandbox/main.go @@ -1,4 +1,28 @@ // Command sandbox runs the Agent Filesystem sandbox server. +// +// SECURITY / THREAT MODEL +// +// The sandbox HTTP API runs arbitrary shell commands sent in the request +// body (POST /processes -> sh -c ). It has no authentication, no +// TLS, and no rate limiting. Any client that can reach the listen address +// can execute code as the sandbox process user, in the workspace directory. +// +// This is intentional: the sandbox is meant to be reached only by tightly +// trusted callers on the same host (e.g. an MCP host running as the same +// user, or a wrapper that gates access). It is NOT a public-facing +// service. +// +// To preserve that assumption the default bind address is 127.0.0.1. +// Binding to 0.0.0.0 or any externally reachable interface requires the +// caller to pass --bind explicitly and accept the consequences. +// +// If you need the sandbox to be reachable across hosts: +// - terminate auth at a proxy that fronts it, AND +// - bind the sandbox itself to 127.0.0.1 (or a unix-only interface), +// so the proxy is the only path in. +// +// MCP transport (--transport stdio) does not open a network socket and is +// not affected by these binding rules. package main import ( @@ -16,6 +40,7 @@ import ( ) func main() { + bind := flag.String("bind", "127.0.0.1", "HTTP server bind address. Default 127.0.0.1; set to 0.0.0.0 to expose externally (see security note in source).") port := flag.Int("port", 8090, "HTTP server port") workspace := flag.String("workspace", "/workspace", "Workspace directory") transport := flag.String("transport", "http", "Transport: http or stdio (MCP)") @@ -35,7 +60,7 @@ func main() { // HTTP server server := api.NewServer(manager) - addr := fmt.Sprintf(":%d", *port) + addr := fmt.Sprintf("%s:%d", *bind, *port) httpServer := &http.Server{ Addr: addr, @@ -52,6 +77,9 @@ func main() { }() log.Printf("Sandbox server listening on %s", addr) + if *bind != "127.0.0.1" && *bind != "localhost" { + log.Printf("WARNING: bind=%s exposes shell-execution endpoints beyond localhost. There is no auth on this API.", *bind) + } log.Printf("Workspace: %s", *workspace) log.Printf("Endpoints:") log.Printf(" POST /processes - Launch process") diff --git a/ui/package-lock.json b/ui/package-lock.json index 960e2b7..4e0e992 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2571,9 +2571,9 @@ }, "node_modules/@redislabsdev/redis-ui-components": { "name": "@redis-ui/components", - "version": "44.0.2", - "resolved": "https://registry.npmjs.org/@redis-ui/components/-/components-44.0.2.tgz", - "integrity": "sha512-ldQzUV14452iVOuOxjnN3U9YbO6xm9UOmKpAHyJ8NZs53WAo4oCjGk4CImkf8cNh8YuDrVVg7RrZKbqFZqmSdg==", + "version": "44.1.0", + "resolved": "https://registry.npmjs.org/@redis-ui/components/-/components-44.1.0.tgz", + "integrity": "sha512-GFmN6yYxCItAmnnf/XUwLd1krCPU8B8ki3T79oQRW9k/8Kv8BKb0C7ZndINHuO0GolFN1wVyhKb3TNf4IihMlg==", "license": "UNLICENSED", "dependencies": { "@radix-ui/react-checkbox": "^1.0.3", @@ -2602,29 +2602,29 @@ "peerDependencies": { "@redis-ui/icons": "^6.9.2", "@redis-ui/styles": "^15.0.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "styled-components": "^5.0.0" } }, "node_modules/@redislabsdev/redis-ui-icons": { "name": "@redis-ui/icons", - "version": "6.9.3", - "resolved": "https://registry.npmjs.org/@redis-ui/icons/-/icons-6.9.3.tgz", - "integrity": "sha512-CHWyqMn7ygGdIshgdgGLEqBd8X2g3XFBQHGhUXuMPgeq1nsYa9jiNbQfx9Hk65kv+AICr+ZfqM12irgAxpyenw==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@redis-ui/icons/-/icons-6.11.0.tgz", + "integrity": "sha512-Myox6U7AGZlyvWoNJq2kZ5frP4g2YiMXpZCtNPSw2cwo17NxEowm8Sswtnp5pdcfh3by/0Z98+zxSdrdxl2G8w==", "license": "UNLICENSED", "peerDependencies": { "@radix-ui/react-id": "^1.1.0", "@redis-ui/styles": "^15.0.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@redislabsdev/redis-ui-styles": { "name": "@redis-ui/styles", - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/@redis-ui/styles/-/styles-15.0.0.tgz", - "integrity": "sha512-Wic8KSOBHk0Idlnxbz4tDHiAW/Kw5ob7FOZdiAn8WReaGR8FKck195x1Pt4ZsyRCJ6gMyRUTW6o/PbhORGth9Q==", + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@redis-ui/styles/-/styles-15.1.0.tgz", + "integrity": "sha512-IKAu6D4WR5uspJabI1U80s8YwM25bscs8HuUhK/hCDUCqLvrTeyDbHNq84mwwXcbdQgW1JnVDDeD/eYVNAy0XQ==", "license": "UNLICENSED", "dependencies": { "color-alpha": "^2.0.0", @@ -2632,16 +2632,16 @@ }, "peerDependencies": { "modern-normalize": "^3.0.1", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "styled-components": "^5.0.0" } }, "node_modules/@redislabsdev/redis-ui-table": { "name": "@redis-ui/table", - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@redis-ui/table/-/table-3.7.0.tgz", - "integrity": "sha512-uLeMgecqwMwdmk7sIxMHlfKPi4ZAivtfW9t2M3CPvlftQM7IY9ur2fwRgF4V8Fl8PrePyQ5V9/38GSSz0CPscg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@redis-ui/table/-/table-3.8.0.tgz", + "integrity": "sha512-qW4vGjX4lkCuxWD3YpLJB8hLfIVUQkYPStUtQOKexvfCcvqfuA8SM3wf0haZlNxO8vuBiBcT7UA9B9Icj6l78g==", "license": "UNLICENSED", "dependencies": { "@redis-ui/components": "^44.0.0", @@ -2651,8 +2651,8 @@ }, "peerDependencies": { "@redis-ui/styles": "^15.0.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "styled-components": "^5.0.0" } }, diff --git a/ui/src/components/afs-situation-room-kit.tsx b/ui/src/components/afs-situation-room-kit.tsx deleted file mode 100644 index f677f59..0000000 --- a/ui/src/components/afs-situation-room-kit.tsx +++ /dev/null @@ -1,297 +0,0 @@ -/** - * AFS · Situation Room primitives - * - * Skin-native components inspired by the Situation Room HTML reference - * (Agent Filesystem Coder Edition/styles.css). They render usefully under - * the classic skin too, but their visual identity belongs to situation-room. - * - * Composition shapes: - * - * - * - * ⌘K - * - */ -import type { ReactNode } from "react"; -import styled from "styled-components"; - -/* ── Panel + PanelHead ─────────────────────────────────────────── */ - -export const Panel = styled.div` - border: 1px solid var(--afs-line-strong, var(--afs-line)); - border-radius: 4px; - background: var(--afs-bg-1, var(--afs-panel-strong)); - position: relative; - overflow: hidden; - - [data-skin="situation-room"] && { - border-radius: var(--afs-r-2); - } -`; - -const PanelHeadRow = styled.div` - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; - gap: 12px; - padding: 8px 12px; - border-bottom: 1px solid var(--afs-line-strong, var(--afs-line)); - background: var(--afs-bg-2, var(--afs-panel)); - font-family: var(--afs-font-mono, var(--afs-mono)); - font-size: var(--afs-fz-xs, 11px); - letter-spacing: 0.1em; - color: var(--afs-ink-dim, var(--afs-muted)); - text-transform: uppercase; -`; - -const Dots = styled.div` - display: flex; - gap: 6px; -`; - -const Dot = styled.span<{ $tone?: "g" | "a" | "r" | "muted" }>` - width: 8px; - height: 8px; - border-radius: 50%; - background: ${({ $tone = "muted" }) => - $tone === "g" - ? "var(--afs-ok, #DCFF1E)" - : $tone === "a" - ? "var(--afs-warn, #FFB547)" - : $tone === "r" - ? "var(--afs-err, #FF4D4D)" - : "var(--afs-line-strong, #284A5E)"}; -`; - -const PanelHeadTitle = styled.div` - color: var(--afs-ink); - font-weight: 500; - text-align: center; - letter-spacing: 0.05em; -`; - -const PanelHeadMeta = styled.div` - text-align: right; - color: var(--afs-ink-dim, var(--afs-muted)); -`; - -export function PanelHead(props: { title?: string; meta?: ReactNode; dots?: boolean }) { - return ( - - {props.dots !== false ? ( - - ) : ( - - )} - {props.title ?? ""} - {props.meta ?? ""} - - ); -} - -export const PanelBody = styled.div` - padding: 16px; - font-family: var(--afs-font-mono, var(--afs-mono)); - font-size: var(--afs-fz-sm, 12px); - color: var(--afs-ink); -`; - -/* ── Terminal block ────────────────────────────────────────────── */ - -export const Term = styled.div` - padding: 16px 18px; - background: var(--afs-bg, #091a23); - color: var(--afs-ink); - font-family: var(--afs-font-mono, var(--afs-mono)); - font-size: var(--afs-fz-sm, 12px); - line-height: 1.65; -`; - -export const TermLine = styled.div` - white-space: pre-wrap; -`; - -export const TermPrompt = styled.span` - color: var(--afs-accent, #DCFF1E); - user-select: none; - margin-right: 8px; -`; - -export const TermOut = styled.span` - color: var(--afs-ink-dim, var(--afs-muted)); -`; - -export const TermOk = styled.span` - color: var(--afs-ok, #DCFF1E); -`; - -export const TermWarn = styled.span` - color: var(--afs-warn, #FFB547); -`; - -export const TermErr = styled.span` - color: var(--afs-err, #FF4D4D); -`; - -export const TermCmt = styled.span` - color: var(--afs-ink-faint, #4A4C48); -`; - -export const TermCursor = styled.span` - display: inline-block; - width: 8px; - height: 1em; - background: var(--afs-accent, #DCFF1E); - vertical-align: -2px; - margin-left: 2px; - animation: afs-blink 1s steps(2, end) infinite; -`; - -/* ── Section header (eyebrow + title + meta) ───────────────────── */ - -const SectionHeadRow = styled.div` - display: grid; - grid-template-columns: auto 1fr auto; - align-items: end; - gap: 24px; - padding: 18px 0 14px; - border-bottom: 1px solid var(--afs-line); - margin-bottom: 24px; - - @media (max-width: 720px) { - grid-template-columns: auto 1fr; - } -`; - -const SectionHeadNum = styled.div` - color: var(--afs-accent); - font-family: var(--afs-font-mono, var(--afs-mono)); - font-size: var(--afs-fz-xs, 11px); - letter-spacing: 0.2em; -`; - -const SectionHeadTitle = styled.div` - color: var(--afs-ink); - font-family: var(--afs-font-mono, var(--afs-mono)); - font-size: var(--afs-fz-lg, 15px); - font-weight: 500; - letter-spacing: 0.04em; -`; - -const SectionHeadMeta = styled.div` - color: var(--afs-ink-dim, var(--afs-muted)); - font-family: var(--afs-font-mono, var(--afs-mono)); - font-size: var(--afs-fz-xs, 11px); - letter-spacing: 0.1em; - text-align: right; - - @media (max-width: 720px) { - grid-column: 1 / -1; - text-align: left; - } -`; - -export function SectionHead(props: { num?: string; title: string; meta?: ReactNode }) { - return ( - - {props.num ?? ""} - {props.title} - {props.meta ? {props.meta} : } - - ); -} - -/* ── KbdKey ────────────────────────────────────────────────────── */ - -export const KbdKey = styled.kbd` - display: inline-block; - padding: 2px 6px; - font-family: var(--afs-font-mono, var(--afs-mono)); - font-size: 10px; - border: 1px solid var(--afs-line-strong, var(--afs-line)); - color: var(--afs-ink-dim, var(--afs-muted)); - background: var(--afs-bg-1, var(--afs-panel)); - border-radius: 2px; - letter-spacing: 0.04em; -`; - -/* ── Spark (animated mini bar chart) ───────────────────────────── */ - -const SparkRow = styled.span` - display: inline-flex; - align-items: end; - gap: 2px; - height: 14px; -`; - -const SparkBar = styled.span<{ $delay: number }>` - display: inline-block; - width: 3px; - background: var(--afs-accent, #DCFF1E); - height: 60%; - animation: afs-spark 1.6s ease-in-out infinite; - animation-delay: ${({ $delay }) => `${$delay}ms`}; -`; - -export function Spark(props: { bars?: number }) { - const count = Math.max(2, Math.min(props.bars ?? 5, 12)); - return ( - - ); -} - -/* ── Eyebrow (small uppercase accent label) ────────────────────── */ - -export const Eyebrow = styled.span` - font-family: var(--afs-font-mono, var(--afs-mono)); - font-size: var(--afs-fz-xs, 11px); - color: var(--afs-accent); - letter-spacing: 0.2em; - text-transform: uppercase; -`; - -/* ── Brackets (decorative around text) ─────────────────────────── */ - -export function Brackets(props: { children: ReactNode }) { - return ( - - [ - {props.children} - ] - - ); -} - -const BracketSpan = styled.span` - color: var(--afs-accent); - font-family: var(--afs-font-mono, var(--afs-mono)); - margin: 0 4px; -`; - -/* ── LED dot (status indicator) ────────────────────────────────── */ - -export const Led = styled.span<{ $tone?: "ok" | "warn" | "err" | "info" }>` - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: ${({ $tone = "ok" }) => - $tone === "warn" - ? "var(--afs-warn)" - : $tone === "err" - ? "var(--afs-err)" - : $tone === "info" - ? "var(--afs-info)" - : "var(--afs-accent)"}; - box-shadow: 0 0 6px currentColor; - animation: afs-blink var(--afs-dur-tick, 1400ms) steps(2, end) infinite; - flex-shrink: 0; -`; diff --git a/ui/src/components/lucide-icons.tsx b/ui/src/components/lucide-icons.tsx index fda5356..9ea4263 100644 --- a/ui/src/components/lucide-icons.tsx +++ b/ui/src/components/lucide-icons.tsx @@ -1,6 +1,6 @@ import type { ComponentType, SVGProps } from "react"; import type { MonochromeIconProps } from "@redis-ui/icons"; -import { useTheme } from "@redislabsdev/redis-ui-styles"; +import { useTheme } from "@redis-ui/styles"; import { Bell, BookOpen, diff --git a/ui/src/features/agent-experience/SiteModeFrame.tsx b/ui/src/features/agent-experience/SiteModeFrame.tsx index cc485ef..e9bdea4 100644 --- a/ui/src/features/agent-experience/SiteModeFrame.tsx +++ b/ui/src/features/agent-experience/SiteModeFrame.tsx @@ -2,7 +2,8 @@ import type { ReactNode } from "react"; import styled from "styled-components"; import { useStoredViewMode } from "../../foundation/hooks/use-stored-view-mode"; import { SiteAgentPane } from "./PublicAgentPane"; -import { SiteModeContext, type SiteMode } from "./site-mode-context"; +import { SiteModeContext } from "./site-mode-context"; +import type { SiteMode } from "./site-mode-context"; import { SiteModeSwitch } from "./SiteModeSwitch"; export function SiteModeFrame({ children }: { children: ReactNode }) { diff --git a/ui/src/features/agent-experience/public-agent-documents.ts b/ui/src/features/agent-experience/public-agent-documents.ts index 2ce1c28..fb2a4e5 100644 --- a/ui/src/features/agent-experience/public-agent-documents.ts +++ b/ui/src/features/agent-experience/public-agent-documents.ts @@ -1,4 +1,5 @@ -import { docsTopicById, docsTopics, type DocsTopic, type DocsTopicId } from "../docs/docs-topics"; +import { docsTopicById, docsTopics } from "../docs/docs-topics"; +import type { DocsTopic, DocsTopicId } from "../docs/docs-topics"; import { bottomNavigationItems, navigationItems, resolveNavigationTitleParts } from "../../layout/navigation-items"; import { publicNavItems, publicRepoLink } from "../../layout/public-navigation"; import { canonicalWorkspaceName, displayWorkspaceName } from "../../foundation/workspace-display"; diff --git a/ui/src/features/agents/CreateMCPAccessDialog.tsx b/ui/src/features/agents/CreateMCPAccessDialog.tsx index 2fc5d75..ed8b108 100644 --- a/ui/src/features/agents/CreateMCPAccessDialog.tsx +++ b/ui/src/features/agents/CreateMCPAccessDialog.tsx @@ -1,5 +1,6 @@ import { Button, Select } from "@redis-ui/components"; -import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { useEffect, useMemo, useState } from "react"; +import type { FormEvent } from "react"; import styled from "styled-components"; import { DialogActions, @@ -259,7 +260,7 @@ export function CreateMCPAccessDialog({ })) } value={workspaceKey} - onChange={(next) => setWorkspaceKey(next as string)} + onChange={(next) => setWorkspaceKey(next)} disabled={options.length === 0} /> @@ -303,7 +304,7 @@ export function CreateMCPAccessDialog({ { value: "never", label: "No expiry" }, ]} value={expiry} - onChange={(next) => setExpiry(next as string)} + onChange={(next) => setExpiry(next)} /> diff --git a/ui/src/features/agents/LocalMCPAccessDialog.tsx b/ui/src/features/agents/LocalMCPAccessDialog.tsx index 31965b2..dbecadb 100644 --- a/ui/src/features/agents/LocalMCPAccessDialog.tsx +++ b/ui/src/features/agents/LocalMCPAccessDialog.tsx @@ -98,7 +98,7 @@ export function LocalMCPAccessDialog({ })) } value={workspaceKey} - onChange={(next) => setWorkspaceKey(next as string)} + onChange={(next) => setWorkspaceKey(next)} disabled={options.length === 0} /> diff --git a/ui/src/features/templates/TemplateInstallDetail.tsx b/ui/src/features/templates/TemplateInstallDetail.tsx index 5210061..d6496c1 100644 --- a/ui/src/features/templates/TemplateInstallDetail.tsx +++ b/ui/src/features/templates/TemplateInstallDetail.tsx @@ -1,5 +1,4 @@ import { Button } from "@redis-ui/components"; -import JSZip from "jszip"; import { useState } from "react"; import styled from "styled-components"; import { SurfaceCard } from "../../components/card-shell"; @@ -64,6 +63,7 @@ export async function downloadClaudePlugin(args: { token: string; }): Promise { const files = buildClaudePlugin(args); + const { default: JSZip } = await import("jszip"); const zip = new JSZip(); for (const file of files) { const isExecutable = file.path.endsWith(".sh"); diff --git a/ui/src/features/workspaces/CreateWorkspaceDialog.tsx b/ui/src/features/workspaces/CreateWorkspaceDialog.tsx index f82254b..e3049e0 100644 --- a/ui/src/features/workspaces/CreateWorkspaceDialog.tsx +++ b/ui/src/features/workspaces/CreateWorkspaceDialog.tsx @@ -1,11 +1,6 @@ import { Button, Select } from "@redis-ui/components"; -import { - useEffect, - useMemo, - useRef, - useState, - type FormEvent, -} from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { FormEvent } from "react"; import { useNavigate } from "@tanstack/react-router"; import styled from "styled-components"; import { @@ -23,20 +18,15 @@ import { } from "../../components/afs-kit"; import { SurfaceCard } from "../../components/card-shell"; import { getControlPlaneURL } from "../../foundation/api/afs"; -import { - type AFSDatabaseScopeRecord, - useDatabaseScope, -} from "../../foundation/database-scope"; +import { useDatabaseScope } from "../../foundation/database-scope"; +import type { AFSDatabaseScopeRecord } from "../../foundation/database-scope"; import { useCreateMCPAccessTokenMutation, useCreateWorkspaceMutation, useImportLocalMutation, } from "../../foundation/hooks/use-afs"; -import { - findTemplate, - type Template, - type TemplateSeedFile, -} from "../templates/templates-data"; +import { findTemplate } from "../templates/templates-data"; +import type { Template, TemplateSeedFile } from "../templates/templates-data"; type SeedMode = "blank" | "import"; type View = "chooser" | "template-form"; @@ -458,7 +448,7 @@ export function CreateWorkspaceDialog({ label: `${database.displayName || database.databaseName}${database.isDefault ? " (default)" : ""}`, }))} value={databaseId} - onChange={(next) => setDatabaseId(next as string)} + onChange={(next) => setDatabaseId(next)} /> ) : null} @@ -589,7 +579,7 @@ export function CreateWorkspaceDialog({ label: `${database.displayName || database.databaseName}${database.isDefault ? " (default)" : ""}`, }))} value={databaseId} - onChange={(next) => setDatabaseId(next as string)} + onChange={(next) => setDatabaseId(next)} /> ) : null} diff --git a/ui/src/foundation/background-pattern.tsx b/ui/src/foundation/background-pattern.tsx deleted file mode 100644 index cfb4467..0000000 --- a/ui/src/foundation/background-pattern.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type { ReactNode } from "react"; - -export function BackgroundPatternProvider({ children }: { children: ReactNode }) { - return <>{children}; -} diff --git a/ui/src/foundation/clipboard-icons.tsx b/ui/src/foundation/clipboard-icons.tsx new file mode 100644 index 0000000..6077ecf --- /dev/null +++ b/ui/src/foundation/clipboard-icons.tsx @@ -0,0 +1,40 @@ +// Inline 11px SVG icons used by table copy-to-clipboard flash buttons. +// Standalone so we never reach for a heavyweight icon import for the smallest +// indicator state in the app. + +export function CopyIcon() { + return ( + + ); +} + +export function CheckIcon() { + return ( + + ); +} diff --git a/ui/src/foundation/sort-compare.ts b/ui/src/foundation/sort-compare.ts new file mode 100644 index 0000000..18b7e17 --- /dev/null +++ b/ui/src/foundation/sort-compare.ts @@ -0,0 +1,13 @@ +// Generic sort comparator used by table components. +// Numbers compare arithmetically; everything else compares as locale strings. +export function compareValues( + left: string | number, + right: string | number, + direction: "asc" | "desc", +) { + const result = + typeof left === "number" && typeof right === "number" + ? left - right + : String(left).localeCompare(String(right)); + return direction === "asc" ? result : result * -1; +} diff --git a/ui/src/foundation/tables/access-tokens-table.tsx b/ui/src/foundation/tables/access-tokens-table.tsx index 44af8f5..74b4c7e 100644 --- a/ui/src/foundation/tables/access-tokens-table.tsx +++ b/ui/src/foundation/tables/access-tokens-table.tsx @@ -1,7 +1,8 @@ import { Button, Typography } from "@redis-ui/components"; import { Table } from "@redis-ui/table"; import type { ColumnDef, SortingState } from "@redis-ui/table"; -import { useMemo, useState, type ReactNode } from "react"; +import { useMemo, useState } from "react"; +import type { ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import styled from "styled-components"; import { @@ -14,8 +15,10 @@ import { } from "../../components/afs-kit"; import { PlugIcon } from "../../components/lucide-icons"; import { getControlPlaneURL } from "../api/afs"; -import { isControlPlaneScope, type AFSMCPProfile, type AFSMCPToken } from "../types/afs"; +import { isControlPlaneScope } from "../types/afs"; +import type { AFSMCPProfile, AFSMCPToken } from "../types/afs"; import { findTemplate } from "../../features/templates/templates-data"; +import { compareValues } from "../sort-compare"; import * as S from "./workspace-table.styles"; type AccessTokenSortField = "name" | "workspaceName" | "lastUsedAt" | "expiresAt" | "createdAt"; @@ -32,18 +35,6 @@ type Props = { revoking?: boolean; }; -function compareValues( - left: string | number, - right: string | number, - direction: "asc" | "desc", -) { - const result = - typeof left === "number" && typeof right === "number" - ? left - right - : String(left).localeCompare(String(right)); - return direction === "asc" ? result : result * -1; -} - function formatProfile(profile: AFSMCPProfile) { switch (profile) { case "workspace-ro": diff --git a/ui/src/foundation/tables/activity-table.tsx b/ui/src/foundation/tables/activity-table.tsx index 0014acf..4f50d23 100644 --- a/ui/src/foundation/tables/activity-table.tsx +++ b/ui/src/foundation/tables/activity-table.tsx @@ -3,6 +3,7 @@ import { Table } from "@redis-ui/table"; import type { ColumnDef, SortingState } from "@redis-ui/table"; import { useMemo, useState } from "react"; import type { AFSActivityEvent } from "../types/afs"; +import { compareValues } from "../sort-compare"; import * as S from "./workspace-table.styles"; type ActivitySortField = "createdAt" | "workspaceName" | "title" | "scope" | "actor"; @@ -16,19 +17,6 @@ type Props = { onOpenActivity: (event: AFSActivityEvent) => void; }; -function compareValues( - left: string | number, - right: string | number, - direction: "asc" | "desc", -) { - const result = - typeof left === "number" && typeof right === "number" - ? left - right - : String(left).localeCompare(String(right)); - - return direction === "asc" ? result : result * -1; -} - export function ActivityTable({ rows, loading = false, diff --git a/ui/src/foundation/tables/changes-table.tsx b/ui/src/foundation/tables/changes-table.tsx index 34525e6..42a3012 100644 --- a/ui/src/foundation/tables/changes-table.tsx +++ b/ui/src/foundation/tables/changes-table.tsx @@ -3,6 +3,7 @@ import { Table } from "@redis-ui/table"; import type { ColumnDef, SortingState } from "@redis-ui/table"; import { useMemo, useState } from "react"; import styled from "styled-components"; +import { compareValues } from "../sort-compare"; import { shortDateTime } from "../time-format"; import type { AFSChangelogEntry } from "../types/afs"; import { truncateMiddlePath } from "./changes-table-utils"; @@ -33,18 +34,6 @@ type Props = { onOpenChange?: (entry: HistoryTableRow) => void; }; -function compareValues( - left: string | number, - right: string | number, - direction: "asc" | "desc", -) { - const result = - typeof left === "number" && typeof right === "number" - ? left - right - : String(left).localeCompare(String(right)); - return direction === "asc" ? result : result * -1; -} - function formatSignedBytes(n?: number): string { if (n === undefined || n === 0) return "—"; const sign = n > 0 ? "+" : "−"; diff --git a/ui/src/foundation/tables/database-table.tsx b/ui/src/foundation/tables/database-table.tsx index 45b140c..5a603f7 100644 --- a/ui/src/foundation/tables/database-table.tsx +++ b/ui/src/foundation/tables/database-table.tsx @@ -17,6 +17,7 @@ import { import { SurfaceCard } from "../../components/card-shell"; import type { AFSDatabaseScopeRecord } from "../database-scope"; import { formatBytes } from "../api/afs"; +import { CheckIcon, CopyIcon } from "../clipboard-icons"; import { redisInsightUrl } from "../redis-insight"; import * as S from "./workspace-table.styles"; import { DenseTableViewport } from "./workspace-table.styles"; @@ -825,43 +826,6 @@ const CopyButton = styled.button` } `; -function CopyIcon() { - return ( - - ); -} - -function CheckIcon() { - return ( - - ); -} - /* ---- Usage cell ---- */ const UsageStack = styled.div` diff --git a/ui/src/foundation/tables/events-table.tsx b/ui/src/foundation/tables/events-table.tsx index 2a3c68c..de5ed9c 100644 --- a/ui/src/foundation/tables/events-table.tsx +++ b/ui/src/foundation/tables/events-table.tsx @@ -3,6 +3,7 @@ import { Table } from "@redis-ui/table"; import type { ColumnDef, SortingState } from "@redis-ui/table"; import { useMemo, useState } from "react"; import type { AFSEventEntry } from "../types/afs"; +import { compareValues } from "../sort-compare"; import * as S from "./workspace-table.styles"; type EventSortField = "createdAt" | "workspaceName" | "kind" | "actor" | "path"; @@ -17,19 +18,6 @@ type Props = { onOpenEvent: (event: AFSEventEntry) => void; }; -function compareValues( - left: string | number, - right: string | number, - direction: "asc" | "desc", -) { - const result = - typeof left === "number" && typeof right === "number" - ? left - right - : String(left).localeCompare(String(right)); - - return direction === "asc" ? result : result * -1; -} - export function EventsTable({ rows, loading = false, diff --git a/ui/src/foundation/tables/workspace-table.tsx b/ui/src/foundation/tables/workspace-table.tsx index c390b0b..fa56317 100644 --- a/ui/src/foundation/tables/workspace-table.tsx +++ b/ui/src/foundation/tables/workspace-table.tsx @@ -10,6 +10,8 @@ import { useNavigate } from "@tanstack/react-router"; import styled, { css, keyframes } from "styled-components"; import { formatBytes } from "../api/afs"; import { useStoredViewMode } from "../hooks/use-stored-view-mode"; +import { CheckIcon, CopyIcon } from "../clipboard-icons"; +import { compareValues } from "../sort-compare"; import { shortDateTime } from "../time-format"; import type { AFSWorkspaceSummary } from "../types/afs"; import { displayWorkspaceName } from "../workspace-display"; @@ -69,19 +71,6 @@ function workspaceRowKey(workspace: AFSWorkspaceSummary) { return `${workspace.databaseId}:${workspace.id}`; } -function compareValues( - left: string | number, - right: string | number, - direction: "asc" | "desc", -) { - const result = - typeof left === "number" && typeof right === "number" - ? left - right - : String(left).localeCompare(String(right)); - - return direction === "asc" ? result : result * -1; -} - export function WorkspaceTable({ rows, loading = false, @@ -620,43 +609,6 @@ const CopyButton = styled.button` } `; -function CopyIcon() { - return ( - - ); -} - -function CheckIcon() { - return ( - - ); -} - const UpdatedStack = styled.div` display: flex; flex-direction: column; diff --git a/ui/src/routes/__root.tsx b/ui/src/routes/__root.tsx index ab6a389..528fe81 100644 --- a/ui/src/routes/__root.tsx +++ b/ui/src/routes/__root.tsx @@ -10,7 +10,6 @@ import styled from "styled-components"; import { SiteModeFrame } from "../features/agent-experience/SiteModeFrame"; import { RouteErrorBoundary } from "../error-boundaries/route-error-boundary"; import { isCloudAdminConfig, useAuthSession } from "../foundation/auth-context"; -import { BackgroundPatternProvider } from "../foundation/background-pattern"; import { AppBar } from "../layout/app-bar"; import { isPublicMarketingPath } from "../layout/public-routes"; import { PublicShell } from "../layout/public-shell"; @@ -150,13 +149,11 @@ function RootLayout() { ); return ( - - - - {humanView} - - - + + + {humanView} + + ); } diff --git a/ui/src/routes/mcp.tsx b/ui/src/routes/mcp.tsx index fc9f9f4..02bb74b 100644 --- a/ui/src/routes/mcp.tsx +++ b/ui/src/routes/mcp.tsx @@ -26,7 +26,8 @@ import { useWorkspaceSummaries, } from "../foundation/hooks/use-afs"; import { AccessTokensTable } from "../foundation/tables/access-tokens-table"; -import { isControlPlaneScope, type AFSMCPToken } from "../foundation/types/afs"; +import { isControlPlaneScope } from "../foundation/types/afs"; +import type { AFSMCPToken } from "../foundation/types/afs"; import { useDrawerCommands } from "../foundation/drawer-context"; import type { CommandsDrawerConfig } from "../foundation/drawer-context"; diff --git a/ui/src/routes/workspace-studio/-checkpoints-tab.tsx b/ui/src/routes/workspace-studio/-checkpoints-tab.tsx index a72dffc..511ce3c 100644 --- a/ui/src/routes/workspace-studio/-checkpoints-tab.tsx +++ b/ui/src/routes/workspace-studio/-checkpoints-tab.tsx @@ -24,6 +24,7 @@ import { useRestoreSavepointMutation, useWorkspaceDiff, } from "../../foundation/hooks/use-afs"; +import { compareValues } from "../../foundation/sort-compare"; import { shortDateTime } from "../../foundation/time-format"; import * as S from "../../foundation/tables/workspace-table.styles"; import { getActiveWorkspaceView } from "../../foundation/workspace-browser-views"; @@ -390,19 +391,6 @@ export function CheckpointsTab({ workspace, onBrowserViewChange, onTabChange }: ); } -function compareValues( - left: string | number, - right: string | number, - direction: "asc" | "desc", -) { - const result = - typeof left === "number" && typeof right === "number" - ? left - right - : String(left).localeCompare(String(right)); - - return direction === "asc" ? result : result * -1; -} - function checkpointSortValue(savepoint: AFSSavepoint, field: CheckpointSortField): string | number { switch (field) { case "createdAt": From 1451b64394d25ddddd7b18e682d1d855822e252a Mon Sep 17 00:00:00 2001 From: Rowan Trollope Date: Tue, 5 May 2026 22:40:57 -0700 Subject: [PATCH 2/6] Clear UI lint baseline (27 -> 0 errors) Type-system mechanical fixes across 12 UI files: drop unnecessary optional chains and ?? fallbacks where TypeScript already guarantees non-null, plus a few small structural fixes. - workspace-table.tsx: pass explicit type param to useStoredViewMode so viewMode is correctly typed as "table" | "cards" rather than the "table" literal it was inferring from the fallback. Clears 6 always-true/false viewMode comparisons. - CreateWorkspaceDialog.tsx: declare preferredDatabase return type explicitly with an early-return on empty list so callers see T | null cleanly. Drop a redundant && startTemplate check now that the startedFromTemplate intermediate is inlined. - database-scope.tsx: drop a dead error != null branch (after the instanceof Error check, the only remaining type is null). - auth-context.tsx: drop redundant ?. on the non-nullable subject field; wrap the auth-config useQuery in queryOptions() to satisfy the @tanstack/query/prefer-query-options rule. - templates.installed.\$workspaceId.tsx: explicit length check instead of foo[0] ?? null so primaryToken's null-or-token type is honest. - lucide-icons, global-drawer, getting-started-onboarding-dialog, agents-table, api/afs.ts, lib/snippets.ts: drop unnecessary ?. and ?? in spots TypeScript already proved non-null. CI: drop continue-on-error from the ui-lint job. Lint now blocks PRs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 4 ---- .../getting-started-onboarding-dialog.tsx | 2 +- ui/src/components/global-drawer.tsx | 4 ++-- ui/src/components/lucide-icons.tsx | 6 +++--- .../workspaces/CreateWorkspaceDialog.tsx | 13 ++++++------ ui/src/foundation/api/afs.ts | 2 +- ui/src/foundation/auth-context.tsx | 20 ++++++++++--------- ui/src/foundation/database-scope.tsx | 4 +--- ui/src/foundation/tables/agents-table.tsx | 2 +- ui/src/foundation/tables/workspace-table.tsx | 7 +++++-- ui/src/lib/snippets.ts | 2 +- .../templates.installed.$workspaceId.tsx | 4 ++-- 12 files changed, 35 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d004da..fe7a9f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,10 +84,6 @@ jobs: ui-lint: name: UI lint runs-on: ubuntu-latest - # The lint job is intentionally non-blocking until the 27 baseline - # @typescript-eslint/no-unnecessary-condition errors are addressed. - # Drop continue-on-error once the baseline is clean. - continue-on-error: true defaults: run: working-directory: ui diff --git a/ui/src/components/getting-started-onboarding-dialog.tsx b/ui/src/components/getting-started-onboarding-dialog.tsx index 25ef492..5c2c8fd 100644 --- a/ui/src/components/getting-started-onboarding-dialog.tsx +++ b/ui/src/components/getting-started-onboarding-dialog.tsx @@ -43,7 +43,7 @@ export function GettingStartedOnboardingDialog({ } const workspaceLabel = displayWorkspaceName(workspaceName); - const agentConnected = (agentsQuery.data ?? []).some( + const agentConnected = agentsQuery.data.some( (agent) => agent.workspaceId === workspaceId, ); diff --git a/ui/src/components/global-drawer.tsx b/ui/src/components/global-drawer.tsx index 770a74a..20346e7 100644 --- a/ui/src/components/global-drawer.tsx +++ b/ui/src/components/global-drawer.tsx @@ -22,7 +22,7 @@ export function GlobalDrawer() { const { state, close } = useDrawer(); const quickstartMutation = useQuickstartMutation(); const workspacesQuery = useScopedWorkspaceSummaries(); - const workspaces = workspacesQuery.data ?? []; + const workspaces = workspacesQuery.data; const haveAnyWorkspace = workspaces.length > 0; if (!state) return null; @@ -49,7 +49,7 @@ export function GlobalDrawer() { : "idle"; const errorMessage = quickstartMutation.isError - ? quickstartMutation.error.message?.includes("cannot connect") + ? quickstartMutation.error.message.includes("cannot connect") ? "Could not connect to Redis at localhost:6379. Start Redis or add a remote database, then retry." : quickstartMutation.error.message || "Something went wrong." : null; diff --git a/ui/src/components/lucide-icons.tsx b/ui/src/components/lucide-icons.tsx index 9ea4263..35d41ff 100644 --- a/ui/src/components/lucide-icons.tsx +++ b/ui/src/components/lucide-icons.tsx @@ -35,12 +35,12 @@ function makeLucideIcon(Icon: LucideIcon, defaultLabel: string) { const theme = useTheme(); const sizeValue = customSize || - theme?.core.icon.size[size] || - theme?.core.icon.size.L || + theme.core.icon.size[size] || + theme.core.icon.size.L || 20; const colorValue = customColor || - (color && theme?.semantic.color.icon[color]) || + (color && theme.semantic.color.icon[color]) || "currentColor"; return ( diff --git a/ui/src/features/workspaces/CreateWorkspaceDialog.tsx b/ui/src/features/workspaces/CreateWorkspaceDialog.tsx index e3049e0..6595fea 100644 --- a/ui/src/features/workspaces/CreateWorkspaceDialog.tsx +++ b/ui/src/features/workspaces/CreateWorkspaceDialog.tsx @@ -42,9 +42,12 @@ function eligibleDatabases(databases: AFSDatabaseScopeRecord[]) { return databases.filter((database) => database.canCreateWorkspaces); } -function preferredDatabase(databases: AFSDatabaseScopeRecord[]) { +function preferredDatabase( + databases: AFSDatabaseScopeRecord[], +): AFSDatabaseScopeRecord | null { const list = eligibleDatabases(databases); - return list.find((database) => database.isDefault) ?? list[0] ?? null; + if (list.length === 0) return null; + return list.find((database) => database.isDefault) ?? list[0]; } function isFreeTierLimitError(error: unknown): boolean { @@ -155,10 +158,8 @@ export function CreateWorkspaceDialog({ const startTemplate = initialTemplateId ? findTemplate(initialTemplateId) ?? null : null; - const startedFromTemplate = - startTemplate != null && startTemplate.id !== "blank"; - if (startedFromTemplate && startTemplate) { + if (startTemplate != null && startTemplate.id !== "blank") { setView("template-form"); setSelectedTemplateId(startTemplate.id); setName(startTemplate.slug); @@ -199,7 +200,7 @@ export function CreateWorkspaceDialog({ function handleFolderPicked(files: FileList | null) { if (!files || files.length === 0) return; - const path = files[0].webkitRelativePath?.split("/")[0] ?? ""; + const path = files[0].webkitRelativePath.split("/")[0]; if (!path) return; setImportPath(path); setImportFileCount(files.length); diff --git a/ui/src/foundation/api/afs.ts b/ui/src/foundation/api/afs.ts index 1611728..78cba71 100644 --- a/ui/src/foundation/api/afs.ts +++ b/ui/src/foundation/api/afs.ts @@ -893,7 +893,7 @@ function normalizeWorkspace(workspace: AFSWorkspace): AFSWorkspace { } function demoWorkspaceContentStorage(workspace: AFSWorkspace) { - const fileCount = workspace.fileCount ?? workspace.files.length; + const fileCount = workspace.fileCount; if (fileCount <= 0) { return { profile: "none" as const, diff --git a/ui/src/foundation/auth-context.tsx b/ui/src/foundation/auth-context.tsx index 4278758..2fd046e 100644 --- a/ui/src/foundation/auth-context.tsx +++ b/ui/src/foundation/auth-context.tsx @@ -1,5 +1,5 @@ import { ClerkProvider, useAuth, useUser } from "@clerk/react"; -import { useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import { createContext, useContext, useMemo } from "react"; import type { PropsWithChildren } from "react"; import { afsApi } from "./api/afs"; @@ -35,7 +35,7 @@ function resolveDisplayName(config: AFSAuthConfig) { if (config.user?.email?.trim()) { return config.user.email.trim(); } - if (config.user?.subject?.trim()) { + if (config.user?.subject.trim()) { return config.user.subject.trim(); } if (config.mode === "clerk") { @@ -124,14 +124,16 @@ function decodeClerkFrontendHost(publishableKey?: string): string | null { } } +const authConfigQuery = queryOptions({ + queryKey: ["afs", "auth", "config"], + queryFn: () => afsApi.getAuthConfig(), + staleTime: 15_000, + gcTime: 10 * 60 * 1000, + retry: 1, +}); + export function AuthProvider(props: PropsWithChildren) { - const authQuery = useQuery({ - queryKey: ["afs", "auth", "config"], - queryFn: () => afsApi.getAuthConfig(), - staleTime: 15_000, - gcTime: 10 * 60 * 1000, - retry: 1, - }); + const authQuery = useQuery(authConfigQuery); const config = authQuery.data ?? defaultAuthConfig; const baseValue = useMemo(() => ({ diff --git a/ui/src/foundation/database-scope.tsx b/ui/src/foundation/database-scope.tsx index 59c5239..2b5f897 100644 --- a/ui/src/foundation/database-scope.tsx +++ b/ui/src/foundation/database-scope.tsx @@ -142,9 +142,7 @@ export function DatabaseScopeProvider(props: PropsWithChildren) { ? null : databasesQuery.error instanceof Error ? databasesQuery.error.message - : databasesQuery.error != null - ? "Unable to load databases." - : null; + : null; const unavailableDatabases = useMemo( () => databases.filter((database) => !database.isHealthy), [databases], diff --git a/ui/src/foundation/tables/agents-table.tsx b/ui/src/foundation/tables/agents-table.tsx index 6b6e4b7..cbaf5b1 100644 --- a/ui/src/foundation/tables/agents-table.tsx +++ b/ui/src/foundation/tables/agents-table.tsx @@ -623,7 +623,7 @@ export function AgentsTable({ {/* ---- MAP (TOPOLOGY) VIEW ---- */} {!loading && !error && filteredRows.length > 0 && viewMode === "map" && mapAvailable ? ( - + ) : null} {/* ---- TABLE VIEW ---- */} diff --git a/ui/src/foundation/tables/workspace-table.tsx b/ui/src/foundation/tables/workspace-table.tsx index fa56317..cde8f46 100644 --- a/ui/src/foundation/tables/workspace-table.tsx +++ b/ui/src/foundation/tables/workspace-table.tsx @@ -87,7 +87,10 @@ export function WorkspaceTable({ const [search, setSearch] = useState(""); const [sortBy, setSortBy] = useState("updatedAt"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); - const [viewMode, setViewMode] = useStoredViewMode("afs.workspaces.viewMode", "table"); + const [viewMode, setViewMode] = useStoredViewMode<"table" | "cards">( + "afs.workspaces.viewMode", + "table", + ); const [copiedId, setCopiedId] = useState(null); const navigate = useNavigate(); useMinuteTick(); @@ -271,7 +274,7 @@ export function WorkspaceTable({ disabled={isDeleting} onClick={(event) => { event.stopPropagation(); - onDeleteWorkspace?.(row.original); + onDeleteWorkspace(row.original); }} >