From edeebf4a8d57aae79cd8620043b52b4e1175160e Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 27 Apr 2026 12:07:04 -0500 Subject: [PATCH 01/12] feat(wasteland): admin review inbox, drawer graph navigation, rigs UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete pass on the Kilo Wasteland admin UI — squash of 73 commits from the feature branch. Net +24k/-2k across 174 files. - Admin Review Inbox (/wasteland/[id]/review): classifies open upstream DoltHub PRs into six typed kinds (rig-registration, wanted-post, wanted-edit, work-submission, admin-action, unknown). Tone-coded per kind, filter chips, search, sort toggle, merge/close/comment actions. - Dedicated Rigs page (/wasteland/[id]/rigs) with search + trust-level filter chips; rig detail drawer with metadata + six activity sections (Open PRs, Posted, Claimed, Completions, Stamps authored, Stamps received). Sections collapsible with animated chevron + height transition. - Stackable drawer graph navigation: every cross-reference inside a drawer (rig handles, wanted item ids, PR submitters) pushes a new drawer on the stack. Overlay-with-backdrop (matches gastown). - Unified wasteland top navbar via a useSetWastelandPageHeader hook consumed by every page (Wanted, Review, Rigs, Members, Settings). - Gastown town settings Wasteland section: connected-wasteland row links into /wasteland/; post-connect dialog offers a "Visit wasteland" primary action. - Wanted board, Rigs list, and detail drawers render markdown in description / reject reason / stamp message fields via MarkdownProse. - Shared DrawerStack primitive at apps/web/src/components/drawer/ with createDrawerStack() factory. Gastown's DrawerStack becomes a thin typed wrapper; wasteland uses the same primitive. Optional header slot so panels can contribute title/badges inline with the close button. ESC/backdrop/ChevronLeft all pop correctly. - New tRPC procedures on services/wasteland: listInboxItems, commentOnUpstreamPR, mergeUpstreamPR, closeUpstreamPR, getRig, getWantedItem, listRigActivity, setUpstreamRigTrust, createUpstream, connectKiloTown, listConnectedTowns, setUpstreamAdmin. - Town ↔ wasteland connection via RPC service binding (typed service stubs between the gastown and wasteland workers). - Container control-server rewrite: Bun-based HTTP handlers for wl CLI invocation; per-request token isolation; init flow hardening. - Durable Object storage + init: first-class persistence layer on the wasteland worker. - Shared settings components at apps/web/src/components/settings/ (SettingsSection, FieldGroup, SettingsStickyHeader, SettingsScrollspyNav, useScrollSpy) consumed by gastown + wasteland. - Shared date helpers at apps/web/src/lib/wasteland/date.ts (parseDoltDate + lastActivityMs) so every DoltHub MySQL DATETIME is parsed as UTC, not browser-local. - Every runUnsafeSql call site gates its interpolations through a tight Zod regex (rig handles [a-zA-Z0-9_-]{1,64}, wanted ids [A-Za-z0-9_.:-]{1,64}). runSql renamed to runUnsafeSql with a doc-comment contract making caller validation responsibility explicit. - Container /wl/init no longer writes the DoltHub token to process.env — tokens live only in the current request's closure + the per-command wl subprocess env. - verifyUpstreamAdmin scratch-branch cleanup runs in finally so it fires exactly once. - globalKeyCounter scoped inside createDrawerStack factory so gastown and wasteland don't share a counter. - PBKDF2 salt comment flags per-credential salt as a followup. - listRigActivity swallowed-404/400 branch now logs with a per-section label so empty sections don't mask upstream failures. - Pushed-as-cross-reference drawers render read-only (actions=null on the entry). Hoisting dialogs into a layout-level provider so pushed drawers also get full actions is an explicit followup — see the `feat(wasteland): stackable drawer graph navigation` discussion. - Hand-written tRPC type declarations at apps/web/src/lib/wasteland/types/ are still the source of truth for FE types; regenerating them manually on every router change is a known papercut. --- .husky/pre-push | 6 + .oxlintrc.json | 4 +- .plans/wasteland-gastown-poc.md | 207 ++ apps/web/.env.development.local.example | 3 + .../src/app/(app)/components/AppSidebar.tsx | 33 + .../components/OrganizationAppSidebar.tsx | 12 + .../(app)/components/PersonalAppSidebar.tsx | 11 + .../settings/TownSettingsPageClient.tsx | 266 +-- .../settings/WastelandSettingsSection.tsx | 926 +++++++++ .../wasteland/OrgWastelandListPageClient.tsx | 124 ++ .../wasteland/[wastelandId]/claims/page.tsx | 17 + .../[id]/wasteland/[wastelandId]/layout.tsx | 22 + .../wasteland/[wastelandId]/members/page.tsx | 17 + .../[id]/wasteland/[wastelandId]/page.tsx | 10 + .../wasteland/[wastelandId]/review/page.tsx | 17 + .../wasteland/[wastelandId]/rigs/page.tsx | 17 + .../wasteland/[wastelandId]/settings/page.tsx | 17 + .../wasteland/[wastelandId]/wanted/page.tsx | 17 + .../organizations/[id]/wasteland/new/page.tsx | 23 + .../organizations/[id]/wasteland/page.tsx | 11 + .../wasteland/WastelandListPageClient.tsx | 114 ++ .../WastelandDashboardHeader.tsx | 89 + .../WastelandPageHeaderContext.tsx | 144 ++ .../[wastelandId]/claims/ClaimsClient.tsx | 5 + .../wasteland/[wastelandId]/claims/page.tsx | 17 + .../(app)/wasteland/[wastelandId]/layout.tsx | 29 + .../[wastelandId]/members/MembersClient.tsx | 563 ++++++ .../wasteland/[wastelandId]/members/page.tsx | 21 + .../(app)/wasteland/[wastelandId]/page.tsx | 20 + .../[wastelandId]/review/ReviewClient.tsx | 703 +++++++ .../wasteland/[wastelandId]/review/page.tsx | 17 + .../[wastelandId]/rigs/RigsClient.tsx | 319 ++++ .../wasteland/[wastelandId]/rigs/page.tsx | 17 + .../[wastelandId]/settings/SettingsClient.tsx | 1212 ++++++++++++ .../[wastelandId]/settings/layout.tsx | 26 + .../wasteland/[wastelandId]/settings/page.tsx | 21 + .../wanted/WantedBoardClient.tsx | 1229 ++++++++++++ .../wasteland/[wastelandId]/wanted/page.tsx | 21 + .../_components/WastelandListComponents.tsx | 111 ++ .../new/NewWastelandWizardClient.tsx | 273 +++ apps/web/src/app/(app)/wasteland/new/page.tsx | 14 + apps/web/src/app/(app)/wasteland/page.tsx | 14 + apps/web/src/app/api/wasteland/token/route.ts | 43 + apps/web/src/components/Providers.tsx | 12 +- .../web/src/components/drawer/DrawerStack.tsx | 336 ++++ apps/web/src/components/drawer/index.ts | 7 + .../components/gastown/AgentDetailDrawer.tsx | 325 ---- .../components/gastown/BeadDetailDrawer.tsx | 240 --- .../src/components/gastown/DrawerStack.tsx | 265 +-- .../components/gastown/EventDetailDrawer.tsx | 238 --- .../gastown/GastownBeadDetailSheet.tsx | 161 -- .../components/gastown/useGastownUiContext.ts | 2 +- .../src/components/settings/FieldGroup.tsx | 25 + .../settings/SettingsScrollspyNav.tsx | 82 + .../components/settings/SettingsSection.tsx | 60 + .../settings/SettingsStickyHeader.tsx | 41 + apps/web/src/components/settings/index.ts | 5 + .../src/components/settings/useScrollSpy.ts | 97 + .../components/wasteland/WastelandSidebar.tsx | 140 ++ .../components/wasteland/drawer/CrossRefs.tsx | 103 + .../wasteland/drawer/ReviewItemPanel.tsx | 551 ++++++ .../components/wasteland/drawer/RigPanel.tsx | 558 ++++++ .../wasteland/drawer/WantedItemByIdPanel.tsx | 60 + .../wasteland/drawer/WantedItemPanel.tsx | 213 +++ .../wasteland/drawer/WastelandDrawerStack.tsx | 24 + .../wasteland/drawer/renderDrawerContent.tsx | 48 + .../src/components/wasteland/drawer/types.ts | 52 + apps/web/src/lib/constants.ts | 5 + apps/web/src/lib/gastown/types/router.d.ts | 372 +++- apps/web/src/lib/gastown/types/schemas.d.ts | 102 + apps/web/src/lib/wasteland/date.ts | 33 + apps/web/src/lib/wasteland/feature-flags.ts | 23 + apps/web/src/lib/wasteland/trpc.ts | 88 + apps/web/src/lib/wasteland/types/README.md | 15 + apps/web/src/lib/wasteland/types/init.d.ts | 58 + apps/web/src/lib/wasteland/types/router.d.ts | 1464 ++++++++++++++ apps/web/src/lib/wasteland/types/schemas.d.ts | 634 +++++++ pnpm-lock.yaml | 164 +- pnpm-workspace.yaml | 1 + services/gastown/container/plugin/client.ts | 47 + .../gastown/container/plugin/mayor-tools.ts | 100 + services/gastown/src/dos/Town.do.ts | 24 + services/gastown/src/dos/town/wasteland.ts | 121 ++ services/gastown/src/gastown.worker.ts | 63 + .../src/handlers/wasteland-tools.handler.ts | 221 +++ services/gastown/src/trpc/router.ts | 83 + .../gastown/src/util/wasteland-client.util.ts | 290 +++ services/gastown/worker-configuration.d.ts | 37 + services/gastown/wrangler.jsonc | 15 + services/wasteland/.gitignore | 3 + services/wasteland/AGENTS.md | 93 + services/wasteland/MONITORING.md | 103 + services/wasteland/container/Dockerfile | 25 + services/wasteland/container/Dockerfile.dev | 27 + .../container/control-server/package.json | 8 + .../container/control-server/server.ts | 1197 ++++++++++++ .../container/control-server/tsconfig.json | 19 + services/wasteland/docs/admin-mode.md | 509 +++++ services/wasteland/docs/e2e-testing.md | 851 +++++++++ services/wasteland/docs/wl-cli-reference.md | 340 ++++ services/wasteland/package.json | 43 + .../src/db/tables/wasteland-config.table.ts | 34 + .../tables/wasteland-connected-towns.table.ts | 25 + .../db/tables/wasteland-credentials.table.ts | 52 + .../src/db/tables/wasteland-members.table.ts | 26 + .../src/db/tables/wasteland-registry.table.ts | 29 + .../db/tables/wasteland-wanted-board.table.ts | 39 + services/wasteland/src/dos/Wasteland.do.ts | 225 +++ .../src/dos/WastelandContainer.do.ts | 79 + .../src/dos/WastelandContainerDO.stub.ts | 15 + .../wasteland/src/dos/WastelandRegistry.do.ts | 156 ++ .../wasteland/src/dos/wasteland/config.ts | 159 ++ .../src/dos/wasteland/credentials.ts | 131 ++ .../wasteland/src/dos/wasteland/members.ts | 143 ++ services/wasteland/src/dos/wasteland/towns.ts | 72 + .../src/dos/wasteland/wanted-board.ts | 53 + .../wasteland/src/inbox/inbox-classifier.ts | 726 +++++++ .../src/middleware/analytics.middleware.ts | 99 + .../src/middleware/auth.middleware.ts | 9 + .../src/middleware/kilo-auth.middleware.ts | 48 + services/wasteland/src/trpc/init.ts | 119 ++ services/wasteland/src/trpc/ownership.ts | 41 + services/wasteland/src/trpc/router.ts | 1686 +++++++++++++++++ services/wasteland/src/trpc/schemas.ts | 295 +++ services/wasteland/src/util/analytics.util.ts | 66 + services/wasteland/src/util/billing.util.ts | 57 + .../wasteland/src/util/crypto.util.test.ts | 144 ++ services/wasteland/src/util/crypto.util.ts | 96 + .../wasteland/src/util/dolthub-api.util.ts | 372 ++++ services/wasteland/src/util/log.util.ts | 28 + services/wasteland/src/util/query.util.ts | 29 + .../wasteland/src/util/rate-limit.util.ts | 83 + services/wasteland/src/util/res.util.ts | 6 + services/wasteland/src/util/secret.util.ts | 20 + services/wasteland/src/util/table.ts | 84 + .../src/wanted-board/wanted-board-ops.ts | 398 ++++ .../wasteland/src/wasteland-rpc.entrypoint.ts | 149 ++ services/wasteland/src/wasteland.worker.ts | 578 ++++++ services/wasteland/tsconfig.json | 15 + services/wasteland/tsconfig.types.json | 11 + services/wasteland/vitest.config.ts | 17 + services/wasteland/worker-configuration.d.ts | 42 + services/wasteland/wrangler.jsonc | 135 ++ 143 files changed, 22753 insertions(+), 1443 deletions(-) create mode 100644 .plans/wasteland-gastown-poc.md create mode 100644 apps/web/src/app/(app)/gastown/[townId]/settings/WastelandSettingsSection.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/wasteland/OrgWastelandListPageClient.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/claims/page.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/layout.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/members/page.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/page.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/review/page.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/rigs/page.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/settings/page.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/wanted/page.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/wasteland/new/page.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/wasteland/page.tsx create mode 100644 apps/web/src/app/(app)/wasteland/WastelandListPageClient.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandDashboardHeader.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandPageHeaderContext.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/claims/ClaimsClient.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/claims/page.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/layout.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/members/MembersClient.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/members/page.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/page.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/review/ReviewClient.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/review/page.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/rigs/RigsClient.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/rigs/page.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/settings/SettingsClient.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/settings/layout.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/settings/page.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/wanted/WantedBoardClient.tsx create mode 100644 apps/web/src/app/(app)/wasteland/[wastelandId]/wanted/page.tsx create mode 100644 apps/web/src/app/(app)/wasteland/_components/WastelandListComponents.tsx create mode 100644 apps/web/src/app/(app)/wasteland/new/NewWastelandWizardClient.tsx create mode 100644 apps/web/src/app/(app)/wasteland/new/page.tsx create mode 100644 apps/web/src/app/(app)/wasteland/page.tsx create mode 100644 apps/web/src/app/api/wasteland/token/route.ts create mode 100644 apps/web/src/components/drawer/DrawerStack.tsx create mode 100644 apps/web/src/components/drawer/index.ts delete mode 100644 apps/web/src/components/gastown/AgentDetailDrawer.tsx delete mode 100644 apps/web/src/components/gastown/BeadDetailDrawer.tsx delete mode 100644 apps/web/src/components/gastown/EventDetailDrawer.tsx delete mode 100644 apps/web/src/components/gastown/GastownBeadDetailSheet.tsx create mode 100644 apps/web/src/components/settings/FieldGroup.tsx create mode 100644 apps/web/src/components/settings/SettingsScrollspyNav.tsx create mode 100644 apps/web/src/components/settings/SettingsSection.tsx create mode 100644 apps/web/src/components/settings/SettingsStickyHeader.tsx create mode 100644 apps/web/src/components/settings/index.ts create mode 100644 apps/web/src/components/settings/useScrollSpy.ts create mode 100644 apps/web/src/components/wasteland/WastelandSidebar.tsx create mode 100644 apps/web/src/components/wasteland/drawer/CrossRefs.tsx create mode 100644 apps/web/src/components/wasteland/drawer/ReviewItemPanel.tsx create mode 100644 apps/web/src/components/wasteland/drawer/RigPanel.tsx create mode 100644 apps/web/src/components/wasteland/drawer/WantedItemByIdPanel.tsx create mode 100644 apps/web/src/components/wasteland/drawer/WantedItemPanel.tsx create mode 100644 apps/web/src/components/wasteland/drawer/WastelandDrawerStack.tsx create mode 100644 apps/web/src/components/wasteland/drawer/renderDrawerContent.tsx create mode 100644 apps/web/src/components/wasteland/drawer/types.ts create mode 100644 apps/web/src/lib/wasteland/date.ts create mode 100644 apps/web/src/lib/wasteland/feature-flags.ts create mode 100644 apps/web/src/lib/wasteland/trpc.ts create mode 100644 apps/web/src/lib/wasteland/types/README.md create mode 100644 apps/web/src/lib/wasteland/types/init.d.ts create mode 100644 apps/web/src/lib/wasteland/types/router.d.ts create mode 100644 apps/web/src/lib/wasteland/types/schemas.d.ts create mode 100644 services/gastown/src/dos/town/wasteland.ts create mode 100644 services/gastown/src/handlers/wasteland-tools.handler.ts create mode 100644 services/gastown/src/util/wasteland-client.util.ts create mode 100644 services/wasteland/.gitignore create mode 100644 services/wasteland/AGENTS.md create mode 100644 services/wasteland/MONITORING.md create mode 100644 services/wasteland/container/Dockerfile create mode 100644 services/wasteland/container/Dockerfile.dev create mode 100644 services/wasteland/container/control-server/package.json create mode 100644 services/wasteland/container/control-server/server.ts create mode 100644 services/wasteland/container/control-server/tsconfig.json create mode 100644 services/wasteland/docs/admin-mode.md create mode 100644 services/wasteland/docs/e2e-testing.md create mode 100644 services/wasteland/docs/wl-cli-reference.md create mode 100644 services/wasteland/package.json create mode 100644 services/wasteland/src/db/tables/wasteland-config.table.ts create mode 100644 services/wasteland/src/db/tables/wasteland-connected-towns.table.ts create mode 100644 services/wasteland/src/db/tables/wasteland-credentials.table.ts create mode 100644 services/wasteland/src/db/tables/wasteland-members.table.ts create mode 100644 services/wasteland/src/db/tables/wasteland-registry.table.ts create mode 100644 services/wasteland/src/db/tables/wasteland-wanted-board.table.ts create mode 100644 services/wasteland/src/dos/Wasteland.do.ts create mode 100644 services/wasteland/src/dos/WastelandContainer.do.ts create mode 100644 services/wasteland/src/dos/WastelandContainerDO.stub.ts create mode 100644 services/wasteland/src/dos/WastelandRegistry.do.ts create mode 100644 services/wasteland/src/dos/wasteland/config.ts create mode 100644 services/wasteland/src/dos/wasteland/credentials.ts create mode 100644 services/wasteland/src/dos/wasteland/members.ts create mode 100644 services/wasteland/src/dos/wasteland/towns.ts create mode 100644 services/wasteland/src/dos/wasteland/wanted-board.ts create mode 100644 services/wasteland/src/inbox/inbox-classifier.ts create mode 100644 services/wasteland/src/middleware/analytics.middleware.ts create mode 100644 services/wasteland/src/middleware/auth.middleware.ts create mode 100644 services/wasteland/src/middleware/kilo-auth.middleware.ts create mode 100644 services/wasteland/src/trpc/init.ts create mode 100644 services/wasteland/src/trpc/ownership.ts create mode 100644 services/wasteland/src/trpc/router.ts create mode 100644 services/wasteland/src/trpc/schemas.ts create mode 100644 services/wasteland/src/util/analytics.util.ts create mode 100644 services/wasteland/src/util/billing.util.ts create mode 100644 services/wasteland/src/util/crypto.util.test.ts create mode 100644 services/wasteland/src/util/crypto.util.ts create mode 100644 services/wasteland/src/util/dolthub-api.util.ts create mode 100644 services/wasteland/src/util/log.util.ts create mode 100644 services/wasteland/src/util/query.util.ts create mode 100644 services/wasteland/src/util/rate-limit.util.ts create mode 100644 services/wasteland/src/util/res.util.ts create mode 100644 services/wasteland/src/util/secret.util.ts create mode 100644 services/wasteland/src/util/table.ts create mode 100644 services/wasteland/src/wanted-board/wanted-board-ops.ts create mode 100644 services/wasteland/src/wasteland-rpc.entrypoint.ts create mode 100644 services/wasteland/src/wasteland.worker.ts create mode 100644 services/wasteland/tsconfig.json create mode 100644 services/wasteland/tsconfig.types.json create mode 100644 services/wasteland/vitest.config.ts create mode 100644 services/wasteland/worker-configuration.d.ts create mode 100644 services/wasteland/wrangler.jsonc diff --git a/.husky/pre-push b/.husky/pre-push index 94c5da8422..6cf3dd2458 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -43,6 +43,12 @@ if [ "${KILO_CLOUD_AGENT:-}" = "1" ] || [ "${KILO_CLOUD_AGENT:-}" = "true" ]; th exit $? fi +# Skip heavy checks in CI/container environments (they run in CI pipeline instead) +if [ -n "${CI:-}" ] || [ -n "${GASTOWN_CONTAINER:-}" ] || [ -n "${KILO_AGENT:-}" ] || [ -f /.dockerenv ]; then + command -v git-lfs >/dev/null 2>&1 && git lfs pre-push "$@" + exit 0 +fi + pnpm format:check & pid_format=$! diff --git a/.oxlintrc.json b/.oxlintrc.json index 0b446e6112..852c2fec24 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -22,7 +22,8 @@ "**/.wrangler/**", "supabase/functions/**", "**/types/opencode.gen.ts", - "**/gastown/types/**" + "**/gastown/types/**", + "**/wasteland/types/**" ], "rules": { "constructor-super": "off", @@ -188,6 +189,7 @@ "services/cloud-agent/src/session/queries/*.ts", "services/cloud-agent-next/src/session/queries/*.ts", "services/gastown/**/*.ts", + "services/wasteland/**/*.ts", "services/webhook-agent-ingest/**/*.ts", "services/kiloclaw/**/*.ts" ], diff --git a/.plans/wasteland-gastown-poc.md b/.plans/wasteland-gastown-poc.md new file mode 100644 index 0000000000..dd16991dd5 --- /dev/null +++ b/.plans/wasteland-gastown-poc.md @@ -0,0 +1,207 @@ +# Wasteland ↔ Gastown POC Plan + +## Current State + +### What's Built + +| Area | Status | Details | +| ---------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Wasteland service** | Complete | WastelandDO (config, members, credentials, connected towns, wanted board), WastelandRegistryDO, WastelandContainerDO, tRPC router (25+ procedures), container image with `wl` CLI + control server | +| **Wasteland UI** | Complete | List/create wastelands, settings (config, DoltHub connection, credentials, connected towns, members, delete), wanted board browse/claim/post/done | +| **Gastown mayor tools** | Complete | 4 container-side tools (`gt_wasteland_browse`, `claim`, `post`, `done`), HTTP client, worker handler routes | +| **Gastown wasteland client** | Complete | Typed HTTP client with service binding + HTTP fallback, mirrors all wasteland tRPC procedures | +| **Container bootstrap** | Complete | `storeCredential` → `setEnvVar` on container → `POST /wl/init` → `wl join` → ready. Env vars persisted in DO storage for cold-start recovery | +| **Town settings UI** | Stub | "Wasteland" section with a "Connect" button that is not wired up | + +### What's Missing (the POC gaps) + +| # | Gap | Impact | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| 1 | **Town has no wasteland state** — `TownConfigSchema` has zero wasteland fields; Town DO stores nothing about connected wastelands | Mayor tools require a `wasteland_id` param but the town doesn't know its wasteland. Mayor can't auto-discover it. | +| 2 | **No onboarding flow** — The "Connect" button in town settings is a TODO. There's no UI to collect DoltHub token, org, rig handle, upstream and bootstrap the connection from within gastown | Users have to manually create a wasteland in the separate wasteland UI, then somehow connect it | +| 3 | **Beads have no wasteland linkage** — No `wasteland_wanted_id` or similar field on beads. Claiming a wanted item doesn't create a bead. Closing a bead doesn't trigger `wl done` | The whole point of the integration is absent — work in the town doesn't flow back to the wasteland | +| 4 | **No auto-evidence on PR** — When a bead associated to a wasteland wanted item gets a PR created/merged, there's no automatic `wl done` with the PR URL as evidence | Manual evidence submission defeats the purpose | +| 5 | **No UI for wasteland-linked beads** — No badge/indicator showing a bead is associated with a wasteland wanted item. No link to the upstream DoltHub PR | Users can't see which work items came from the wasteland | +| 6 | **Mayor doesn't auto-know its wasteland** — System prompt doesn't include wasteland context. Mayor can't proactively browse or suggest wasteland work | The mayor is blind to the wasteland unless explicitly told the wasteland ID each time | + +## POC Scope + +Focus: **Connect to Commons** — a single wasteland upstream (`hop/wl-commons`). No "create your own" flow. + +### Workstreams + +--- + +### WS1: Town DO Wasteland State + +Add wasteland connection state to the Town DO so it persists across alarms and the mayor can auto-discover it. + +**Schema changes** — new `town_wasteland_connections` table in Town DO SQLite: + +``` +town_wasteland_connections + connection_id TEXT PK + wasteland_id TEXT NOT NULL + upstream TEXT NOT NULL -- e.g. "hop/wl-commons" + rig_handle TEXT NOT NULL + dolthub_org TEXT NOT NULL + connected_at TEXT NOT NULL (ISO) + status TEXT NOT NULL CHECK (status IN ('active', 'disconnecting')) +``` + +**Town config additions** — add to `TownConfigSchema`: + +```ts +wasteland_connection?: { + wasteland_id: string; + upstream: string; + rig_handle: string; + dolthub_org: string; +} +``` + +**Town DO sub-module** — `services/gastown/src/dos/town/wasteland.ts`: + +- `connectWasteland(sql, { wasteland_id, upstream, rig_handle, dolthub_org })` +- `disconnectWasteland(sql, wasteland_id)` +- `getWastelandConnection(sql) → connection | null` + +**tRPC procedures** on gastown router: + +- `connectTownToWasteland` — stores connection in Town DO, calls wasteland service `connectKiloTown` +- `disconnectTownFromWasteland` — removes connection, calls wasteland service `disconnectKiloTown` +- `getTownWastelandConnection` — returns current connection (or null) + +--- + +### WS2: Onboarding Flow (Town Settings → Wasteland) + +The Connect button in town settings opens a multi-step dialog that never leaves the gastown UI. + +**Step 1: DoltHub Credentials** + +- DoltHub API token (password field) +- DoltHub org / username +- Dolt credential JWK (password field, optional) + +**Step 2: Rig Identity** + +- Rig handle (auto-suggested from town name, e.g. `kilo-{town-name}`) +- Dolt user name (pre-filled from user profile) +- Dolt user email (pre-filled from user email) + +**Step 3: Confirm & Connect** + +- Shows summary: "Connecting to hop/wl-commons as `{rig_handle}` via `{dolthub_org}`" +- On confirm: + 1. Call wasteland `storeCredential` (encrypts token, pushes env vars to container, triggers `wl join`) + 2. Call wasteland `connectKiloTown` (creates town↔wasteland association) + 3. Call gastown `connectTownToWasteland` (persists in Town DO) + 4. Wait for container init to complete (poll `containerStatus`) +- Success state: "Town connected to hop/wl-commons. Agents now have access to wasteland tools. Try asking the mayor to browse the wasteland." + +**After connection, the settings section shows:** + +- Connected wasteland name + upstream +- Rig handle +- Disconnect button + +--- + +### WS3: Bead ↔ Wasteland Linkage + +When the mayor claims a wasteland item and creates work in the town, the resulting bead must be linked. + +**Bead metadata fields** (stored in `beads.metadata` JSON): + +```ts +{ + wasteland_wanted_id?: string; // e.g. "w-abc123" + wasteland_id?: string; // which wasteland + wasteland_upstream?: string; // e.g. "hop/wl-commons" + wasteland_evidence_url?: string; // set when evidence submitted + wasteland_published_at?: string; // ISO timestamp of wl done +} +``` + +No schema migration needed — `metadata` is already a JSON column. + +**Mayor tool update** — when `gt_wasteland_claim` succeeds, the mayor should create a bead (via sling) with the wasteland metadata attached. The claim tool response should include the wanted item details so the mayor can create an appropriate bead. + +**Auto-evidence on PR** — in the bead lifecycle, when a bead with `wasteland_wanted_id` gets a PR created: + +- Store the PR URL as `wasteland_evidence_url` on bead metadata +- When the bead closes (or PR merges), auto-call `wl done` with the PR URL as evidence +- This likely hooks into the existing reconciler/alarm where PR status is checked + +--- + +### WS4: Mayor Wasteland Context + +The mayor needs to know about its connected wasteland without being told each time. + +**System prompt injection** — when generating the mayor's system prompt, if the town has a wasteland connection, append: + +``` +## Wasteland + +This town is connected to the wasteland "hop/wl-commons" (wasteland ID: {id}). +You have tools to browse the wanted board, claim items, post new items, and +submit evidence of completion. When a user asks about wasteland work or +available bounties, use gt_wasteland_browse. When creating work from a +wasteland item, note the wasteland_wanted_id in the bead metadata. +``` + +**Tool availability** — the wasteland tools (`gt_wasteland_browse`, etc.) should only be included in the mayor's tool list when a wasteland connection exists. Currently they're always registered — gate them behind `townConfig.wasteland_connection != null`. + +**Auto-populate `wasteland_id` param** — the mayor tools currently require `wasteland_id` as an explicit parameter. Since the town knows its connected wasteland, auto-fill this from town config so the mayor doesn't have to specify it. + +--- + +### WS5: UI for Wasteland-Linked Beads + +**Bead card indicator** — when a bead has `wasteland_wanted_id` in its metadata, show a small wasteland badge/icon on the bead card (e.g., a Globe icon with "Wasteland" tooltip). + +**Bead detail** — in the bead detail view, if wasteland metadata exists, show: + +- "Wasteland Item: {wanted_id}" (linked to the wasteland wanted board) +- Evidence status (submitted / pending) +- If there's an upstream DoltHub PR, link to it + +**Town overview** — optionally, show a count of active wasteland-linked beads in the town dashboard. + +--- + +### WS6: Wasteland Service Fixes + +From issue exploration and current state: + +- **Container cold-start reconciliation** — when a container wakes from sleep, the `selfInit()` already handles this via env vars persisted in DO storage. Verify this works end-to-end. +- **Dolt credential JWK** — the UI field was changed to password-masked input. Verify the credential flow works with JWK provided (needed for `dolt push` in PR-mode mutations). + +--- + +## Implementation Order + +| Phase | Workstreams | What it enables | +| ----------- | ------------------------------------------- | --------------------------------------------------------------------------------- | +| **Phase A** | WS1 (Town DO state) + WS2 (Onboarding flow) | User can connect a town to the commons from the gastown UI | +| **Phase B** | WS4 (Mayor context) + WS3 (Bead linkage) | Mayor auto-knows the wasteland, claimed items become beads, completions flow back | +| **Phase C** | WS5 (UI indicators) | Users can see which beads came from the wasteland | +| **Phase D** | WS6 (Service fixes) | Polish and reliability | + +## Ideas from Issues + +### From #1810 (Hosted Wasteland Service) + +- ✅ Already implemented: per-wasteland containers, encrypted credential storage, `sleepAfter: 30m`, control server with 8 endpoints, DoltHub REST API reads from DO (no container needed for browse) +- Relevant for POC: the "hybrid read" model — browse reads from DO's DoltHub SQL API (instant, no cold start), writes go through container. Already built. + +### From #1040 (Wasteland Integration — Town Side) + +- **Reconciler events** (`wasteland_completion_ready`, `wasteland_published`, etc.) — good design but heavier than needed for POC. For POC, the auto-evidence can be simpler: hook into the existing PR-merge detection in the alarm loop. +- **TownConfig schema** from #1040 proposed multi-wasteland connections. For POC: single connection is sufficient. The schema should allow for future multi-connection but the UI only supports one. +- **Rig registration** details: `rig_type = 'agent'`, `parent_rig = `, `trust_level = 1`. We should set these during onboarding. The `wl join` command handles rig registration — need to verify it accepts these fields or if we need to configure them separately. +- **`rig_links` table** — linking the agent rig to the human owner's rig. This is a nice-to-have for the POC, not blocking. +- **Sandbox fields** (`sandbox_required`, `sandbox_scope`, `sandbox_min_tier`) on wanted items — noted in bead metadata for future container env config. Not needed for POC. +- **Stale completion escalation** (published > 7d, not validated → escalation bead for mayor) — good idea, defer to post-POC. diff --git a/apps/web/.env.development.local.example b/apps/web/.env.development.local.example index cf55a84415..4323c0f2d8 100644 --- a/apps/web/.env.development.local.example +++ b/apps/web/.env.development.local.example @@ -42,3 +42,6 @@ NEXT_PUBLIC_KILO_CHAT_URL=http://localhost:8808 # @url event-service NEXT_PUBLIC_EVENT_SERVICE_URL=ws://localhost:8809 + +# @url wasteland +# NEXT_PUBLIC_WASTELAND_URL=http://localhost:8790 diff --git a/apps/web/src/app/(app)/components/AppSidebar.tsx b/apps/web/src/app/(app)/components/AppSidebar.tsx index b82e0f8da1..0496ffa13f 100644 --- a/apps/web/src/app/(app)/components/AppSidebar.tsx +++ b/apps/web/src/app/(app)/components/AppSidebar.tsx @@ -7,6 +7,7 @@ import { useUrlOrganizationId } from '@/hooks/useUrlOrganizationId'; import PersonalAppSidebar from './PersonalAppSidebar'; import OrganizationAppSidebar from './OrganizationAppSidebar'; import { GastownTownSidebar } from '@/components/gastown/GastownTownSidebar'; +import { WastelandSidebar } from '@/components/wasteland/WastelandSidebar'; const UUID = '[0-9a-f-]{36}'; @@ -26,6 +27,18 @@ function isKiloClawNewPath(pathname: string): boolean { return pathname === '/claw/new' || new RegExp(`^/organizations/${UUID}/claw/new$`).test(pathname); } +/** Extract the wastelandId from a /wasteland/[wastelandId] pathname, or null. */ +function extractWastelandId(pathname: string): string | null { + const match = pathname.match(new RegExp(`^/wasteland/(${UUID})`)); + return match ? match[1] : null; +} + +/** Extract {orgId, wastelandId} from an /organizations/[id]/wasteland/[wastelandId] pathname, or null. */ +function extractOrgWastelandId(pathname: string): { orgId: string; wastelandId: string } | null { + const match = pathname.match(new RegExp(`^/organizations/(${UUID})/wasteland/(${UUID})`)); + return match ? { orgId: match[1], wastelandId: match[2] } : null; +} + export default function AppSidebar(props: React.ComponentProps) { const currentOrgId = useUrlOrganizationId(); const pathname = usePathname(); @@ -78,6 +91,26 @@ export default function AppSidebar(props: React.ComponentProps) ); } + // Personal wasteland — show the wasteland-specific sidebar + const wastelandId = extractWastelandId(pathname); + if (wastelandId) { + return ; + } + + // Org wasteland — show the same sidebar with org-prefixed paths + const orgWasteland = extractOrgWastelandId(pathname); + if (orgWasteland) { + const orgBase = `/organizations/${orgWasteland.orgId}`; + return ( + + ); + } + // Render organization sidebar if viewing an organization if (currentOrgId) { return ; diff --git a/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx b/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx index 6d02aa4d51..0811f30b14 100644 --- a/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx +++ b/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx @@ -23,6 +23,7 @@ import { ListChecks, Wrench, Webhook, + Skull, Settings, MessageSquare, MessagesSquare, @@ -59,6 +60,7 @@ export default function OrganizationAppSidebar({ // Feature flags const isAutoTriageFeatureEnabled = useFeatureFlagEnabled('auto-triage-feature'); const isKiloChatEnabled = useFeatureFlagEnabled('kilo-chat-feature'); + const isWastelandEnabled = useFeatureFlagEnabled('wasteland-access'); const isDevelopment = process.env.NODE_ENV === 'development'; // Get current organization role and data @@ -200,6 +202,16 @@ export default function OrganizationAppSidebar({ }, ] : []), + // Wasteland requires feature flag + non-billing_manager role + ...((isWastelandEnabled || isDevelopment) && currentRole !== 'billing_manager' + ? [ + { + title: 'Wastelands', + icon: Skull, + url: `/organizations/${organizationId}/wasteland`, + }, + ] + : []), { title: 'Code Reviewer', icon: Bot, diff --git a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx index a51bb7d261..145daa3c4a 100644 --- a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx +++ b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx @@ -26,6 +26,7 @@ import { Wrench, Webhook, Factory, + Skull, Settings, CreditCard, MessageSquare, @@ -52,6 +53,7 @@ export default function PersonalAppSidebar(props: React.ComponentProps(sectionIds[0]); - const suppressRef = useRef(false); - - useEffect(() => { - const observer = new IntersectionObserver( - entries => { - if (suppressRef.current) return; - // Find the topmost visible section - const visible = entries - .filter(e => e.isIntersecting) - .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); - if (visible.length > 0) { - setActiveId(visible[0].target.id); - } - }, - { rootMargin: '-56px 0px -60% 0px', threshold: 0 } - ); - - for (const id of sectionIds) { - const el = document.getElementById(id); - if (el) observer.observe(el); - } - - return () => observer.disconnect(); - }, [sectionIds]); - - function scrollTo(id: string) { - const el = document.getElementById(id); - const header = document.getElementById('settings-sticky-header'); - if (!el) return; - - // Immediately highlight the target and suppress observer during scroll - setActiveId(id); - suppressRef.current = true; - - const headerHeight = header?.getBoundingClientRect().height ?? 0; - const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - 24; - window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); - - // Re-enable observer after scroll settles - setTimeout(() => { - suppressRef.current = false; - }, 1000); - } - - return { activeId, scrollTo }; -} - export function TownSettingsPageClient({ townId, readOnly = false, organizationId }: Props) { const trpc = useGastownTRPC(); const queryClient = useQueryClient(); @@ -338,7 +299,8 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI } const { activeId: activeSection, scrollTo: scrollToSection } = useScrollSpy( - SECTIONS.map(s => s.id) + SECTIONS.map(s => s.id), + { stickyHeaderId: 'settings-sticky-header' } ); function handleSave() { @@ -519,35 +481,28 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI return (
- {/* Top bar */} -
-
- - -

Settings

- {townQuery.data?.name} -
- {!effectiveReadOnly && ( - - )} - {effectiveReadOnly && ( - - View only — only town creators and org owners can edit - - )} -
+ } + actions={ + !effectiveReadOnly ? ( + + ) : ( + + View only — only town creators and org owners can edit + + ) + } + /> {/* Two-column body — viewport scrolls so sticky works */}
@@ -1215,6 +1170,17 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI
+ {/* ── Wasteland ──────────────────────────────────────── */} + + + + {/* ── Custom Instructions ────────────────────────────────── */}
- {/* Right sidebar — sticky scrollspy nav */} -
- -
+ + + {updateConfig.isPending ? 'Saving...' : 'Save'} + + ) : null + } + /> ); } -// ── Shared sub-components ──────────────────────────────────────────────── - -function SettingsSection({ - id, - title, - description, - icon: Icon, - index, - action, - children, -}: { - id: string; - title: string; - description: string; - icon: typeof Settings; - index: number; - action?: React.ReactNode; - children: React.ReactNode; -}) { - return ( - -
-
-
- -
-
-

{title}

-

{description}

-
-
- {action} -
-
{children}
-
- ); -} - -function FieldGroup({ - label, - hint, - children, -}: { - label: string; - hint?: string; - children: React.ReactNode; -}) { - return ( -
- - {children} - {hint &&

{hint}

} -
- ); -} +// ── Local sub-components ───────────────────────────────────────────────── function MergeStrategyOption({ selected, diff --git a/apps/web/src/app/(app)/gastown/[townId]/settings/WastelandSettingsSection.tsx b/apps/web/src/app/(app)/gastown/[townId]/settings/WastelandSettingsSection.tsx new file mode 100644 index 0000000000..dc11093920 --- /dev/null +++ b/apps/web/src/app/(app)/gastown/[townId]/settings/WastelandSettingsSection.tsx @@ -0,0 +1,926 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useGastownTRPC, useGastownTRPCClient } from '@/lib/gastown/trpc'; +import { useWastelandTRPC, useWastelandTRPCClient } from '@/lib/wasteland/trpc'; +import { useUser } from '@/hooks/useUser'; +import { Button } from '@/components/Button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Globe, + Loader2, + CheckCircle2, + Unlink, + Skull, + Users, + Rocket, + ChevronLeft, + ArrowRight, +} from 'lucide-react'; +import Link from 'next/link'; + +const DEFAULT_UPSTREAM = 'hop/wl-commons'; + +type WastelandConnection = { + connection_id: string; + wasteland_id: string; + upstream: string; + rig_handle: string; + dolthub_org: string; + connected_at: string; + status: 'active' | 'disconnecting'; +}; + +export function WastelandSettingsSection({ + townId, + readOnly, +}: { + townId: string; + readOnly: boolean; +}) { + const gastownTrpc = useGastownTRPC(); + const queryClient = useQueryClient(); + + const connectionQuery = useQuery( + gastownTrpc.gastown.getTownWastelandConnection.queryOptions({ townId }) + ); + + const connection = connectionQuery.data; + const isLoading = connectionQuery.isLoading; + + if (isLoading) { + return ( +
+ + Checking connection... +
+ ); + } + + if (connection) { + return ( + + ); + } + + return ; +} + +// ── Connected State ────────────────────────────────────────────────────── + +function ConnectedState({ + townId, + connection, + readOnly, + queryClient, +}: { + townId: string; + connection: WastelandConnection; + readOnly: boolean; + queryClient: ReturnType; +}) { + const gastownTrpc = useGastownTRPC(); + + const disconnect = useMutation( + gastownTrpc.gastown.disconnectTownFromWasteland.mutationOptions({ + onSuccess: () => { + toast.success('Disconnected from wasteland'); + void queryClient.invalidateQueries({ + queryKey: gastownTrpc.gastown.getTownWastelandConnection.queryKey({ townId }), + }); + }, + onError: err => toast.error(`Failed to disconnect: ${err.message}`), + }) + ); + + // Admin-mode toggle lives on the wasteland settings page (which owns the + // credential + upstream config), not here. This section only shows + // whether the town is wired to a wasteland. + return ( +
+ + +
+

+ Connected to {connection.upstream} +

+

+ Rig: {connection.rig_handle} + {' · '} + Org: {connection.dolthub_org} +

+
+ + + +
+ ); +} + +// ── Disconnected State ─────────────────────────────────────────────────── + +function DisconnectedState({ + townId, + readOnly, + queryClient, +}: { + townId: string; + readOnly: boolean; + queryClient: ReturnType; +}) { + const [open, setOpen] = useState(false); + + return ( + <> +
+
+

Not connected

+

+ Link this town to a Wasteland to enable community bounties and shared contributions. +

+
+ +
+ + + ); +} + +// ── Connect Dialog ─────────────────────────────────────────────────────── + +type Mode = 'join' | 'create'; +type Step = + | 'intent' + | 'select' + | 'new-details' + | 'credentials' + | 'identity' + | 'connecting' + | 'success'; + +function ConnectWastelandDialog({ + townId, + open, + onOpenChange, + queryClient, +}: { + townId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + queryClient: ReturnType; +}) { + const gastownTrpc = useGastownTRPC(); + const wastelandTrpc = useWastelandTRPC(); + const gastownClient = useGastownTRPCClient(); + const wastelandClient = useWastelandTRPCClient(); + const { data: currentUser } = useUser(); + + const [step, setStep] = useState('intent'); + const [mode, setMode] = useState('join'); + + // Existing wastelands + const wastelandsQuery = useQuery(wastelandTrpc.wasteland.listWastelands.queryOptions({})); + const wastelands = wastelandsQuery.data ?? []; + const [selectedWastelandId, setSelectedWastelandId] = useState(null); + + // Create-mode details + const [newWastelandName, setNewWastelandName] = useState(''); + const [upstreamInput, setUpstreamInput] = useState(DEFAULT_UPSTREAM); + + // Step: Credentials + const [dolthubToken, setDolthubToken] = useState(''); + const [dolthubOrg, setDolthubOrg] = useState(''); + const [doltCredsJwk, setDoltCredsJwk] = useState(''); + // User explicitly attests they own the upstream. Create mode implies + // this is true (they're creating the repo) but keep it toggleable so + // they can opt back out. + const [isUpstreamAdmin, setIsUpstreamAdmin] = useState(false); + + // Step: Identity + const [rigHandle, setRigHandle] = useState(''); + const [doltUserName, setDoltUserName] = useState(''); + const [doltUserEmail, setDoltUserEmail] = useState(''); + + const [connectedUpstream, setConnectedUpstream] = useState(DEFAULT_UPSTREAM); + /** wastelandId that just got connected — used to offer a "Visit wasteland" + * link in the success step. */ + const [connectedWastelandId, setConnectedWastelandId] = useState(null); + const [error, setError] = useState(null); + + // Pre-fill identity fields from user profile + const handleProceedToIdentity = () => { + const displayName = currentUser?.google_user_name; + const email = currentUser?.google_user_email; + if (!rigHandle && displayName) { + setRigHandle(`kilo-${displayName.toLowerCase().replace(/[^a-z0-9-]/g, '-')}`); + } + if (!doltUserName && displayName) { + setDoltUserName(displayName); + } + if (!doltUserEmail && email) { + setDoltUserEmail(email); + } + setStep('identity'); + }; + + // Reset state when dialog closes + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + setStep('intent'); + setMode('join'); + setSelectedWastelandId(null); + setConnectedUpstream(DEFAULT_UPSTREAM); + setConnectedWastelandId(null); + setUpstreamInput(DEFAULT_UPSTREAM); + setNewWastelandName(''); + setDolthubToken(''); + setDolthubOrg(''); + setDoltCredsJwk(''); + setIsUpstreamAdmin(false); + setRigHandle(''); + setDoltUserName(''); + setDoltUserEmail(''); + setError(null); + } + onOpenChange(nextOpen); + }; + + const handlePickMode = (next: Mode) => { + setMode(next); + setIsUpstreamAdmin(next === 'create'); + setStep(next === 'join' ? 'select' : 'new-details'); + }; + + const handleSelectWasteland = async (wastelandId: string) => { + setSelectedWastelandId(wastelandId); + + // If credentials are already stored for this wasteland, skip straight + // to the connection — the user has already connected their DoltHub + // account to this wasteland and doesn't need to re-enter them. + try { + const existing = await wastelandClient.wasteland.getCredentialStatus.query({ + wastelandId, + }); + if (existing) { + await connectTownToExistingWasteland( + wastelandId, + existing.rig_handle ?? '', + existing.dolthub_org + ); + return; + } + } catch (err) { + // If the check fails, fall through to the credentials step + console.error('Failed to check wasteland credentials', err); + } + + setStep('credentials'); + }; + + const connectTownToExistingWasteland = async ( + wastelandId: string, + existingRigHandle: string, + existingDolthubOrg: string + ) => { + setStep('connecting'); + setError(null); + + try { + const selectedWasteland = wastelands.find(w => w.wasteland_id === wastelandId); + const upstream = selectedWasteland?.dolthub_upstream ?? DEFAULT_UPSTREAM; + setConnectedUpstream(upstream); + setConnectedWastelandId(wastelandId); + + await wastelandClient.wasteland.connectKiloTown.mutate({ + wastelandId, + townId, + }); + + await gastownClient.gastown.connectTownToWasteland.mutate({ + townId, + wastelandId, + upstream, + rigHandle: existingRigHandle, + dolthubOrg: existingDolthubOrg, + }); + + void queryClient.invalidateQueries({ + queryKey: gastownTrpc.gastown.getTownWastelandConnection.queryKey({ townId }), + }); + + setRigHandle(existingRigHandle); + setStep('success'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Connection failed'); + setStep('select'); + } + }; + + const handleConnectJoin = async () => { + setStep('connecting'); + setError(null); + + try { + const wastelandId = selectedWastelandId; + const selectedWasteland = wastelands.find(w => w.wasteland_id === wastelandId); + const upstream = selectedWasteland?.dolthub_upstream ?? DEFAULT_UPSTREAM; + setConnectedUpstream(upstream); + + // Brand-new wasteland record is only created when the user picked + // a non-listed upstream — this only applies if they used the + // intent flow to "join" a new, unknown upstream. For now we still + // require a selection on the join branch. + if (!wastelandId) { + throw new Error('Select a wasteland to join, or switch to Create.'); + } + setConnectedWastelandId(wastelandId); + + await wastelandClient.wasteland.storeCredential.mutate({ + wastelandId, + dolthubToken, + dolthubOrg, + rigHandle, + doltCredsJwk: doltCredsJwk.trim() || undefined, + doltUserName: doltUserName.trim() || undefined, + doltUserEmail: doltUserEmail.trim() || undefined, + isUpstreamAdmin, + }); + + await wastelandClient.wasteland.connectKiloTown.mutate({ + wastelandId, + townId, + }); + + await gastownClient.gastown.connectTownToWasteland.mutate({ + townId, + wastelandId, + upstream, + rigHandle, + dolthubOrg, + }); + + void queryClient.invalidateQueries({ + queryKey: gastownTrpc.gastown.getTownWastelandConnection.queryKey({ townId }), + }); + + setStep('success'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Connection failed'); + setStep('identity'); + } + }; + + const handleConnectCreate = async () => { + setStep('connecting'); + setError(null); + + try { + const upstream = upstreamInput.trim(); + setConnectedUpstream(upstream); + + const name = + newWastelandName.trim() || `${dolthubOrg}-${upstream.split('/')[1] ?? 'wasteland'}`; + + // 1. Create the wasteland record (auto-adds caller as owner member) + const created = await wastelandClient.wasteland.createWasteland.mutate({ + name, + ownerType: 'user', + dolthubUpstream: upstream, + }); + const wastelandId = created.wasteland_id; + setConnectedWastelandId(wastelandId); + + // 2. Store credentials with admin flag (required for createUpstream) + await wastelandClient.wasteland.storeCredential.mutate({ + wastelandId, + dolthubToken, + dolthubOrg, + rigHandle, + doltCredsJwk: doltCredsJwk.trim() || undefined, + doltUserName: doltUserName.trim() || undefined, + doltUserEmail: doltUserEmail.trim() || undefined, + isUpstreamAdmin: true, + }); + + // 3. Bootstrap the DoltHub repo + register the creator as the first rig. + await wastelandClient.wasteland.createUpstream.mutate({ + wastelandId, + upstream, + rigHandle, + rigDisplayName: doltUserName.trim() || undefined, + rigEmail: doltUserEmail.trim() || undefined, + }); + + // 4. Persist the town↔wasteland association on the Town DO. + // createWasteland already added the user as a wasteland member, + // so connectKiloTown isn't strictly required — but we still need + // the Gastown-side connection for the mayor tools. + await wastelandClient.wasteland.connectKiloTown.mutate({ + wastelandId, + townId, + }); + await gastownClient.gastown.connectTownToWasteland.mutate({ + townId, + wastelandId, + upstream, + rigHandle, + dolthubOrg, + }); + + void queryClient.invalidateQueries({ + queryKey: gastownTrpc.gastown.getTownWastelandConnection.queryKey({ townId }), + }); + + setStep('success'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Create failed'); + setStep('identity'); + } + }; + + const handleConnect = () => { + if (mode === 'create') return handleConnectCreate(); + return handleConnectJoin(); + }; + + const upstreamValid = /^[^/\s]+\/[^/\s]+$/.test(upstreamInput.trim()); + const newDetailsValid = upstreamValid && newWastelandName.trim().length > 0; + const credentialsValid = dolthubToken.trim().length > 0 && dolthubOrg.trim().length > 0; + const identityValid = rigHandle.trim().length > 0; + + return ( + + + {step === 'intent' && ( + <> + + Connect to Wasteland + + Join an existing wasteland to contribute, or create your own. + + +
+ + +
+ + + + + )} + + {step === 'select' && ( + <> + + Pick a Wasteland to Join + + Select one of your existing wastelands below. + + +
+ {wastelandsQuery.isLoading ? ( +
+ + Loading wastelands... +
+ ) : wastelands.filter(w => w.status === 'active').length > 0 ? ( + wastelands + .filter(w => w.status === 'active') + .map(w => ( + + )) + ) : ( +
+ +

No wastelands to join yet.

+

+ Go back and pick "Create your own" to bootstrap one. +

+
+ )} +
+ + + + + + )} + + {step === 'new-details' && ( + <> + + Create a Wasteland + + Name your wasteland and pick the DoltHub repo to bootstrap. We'll create the repo + for you and register your first rig. + + +
+ + setNewWastelandName(e.target.value)} + placeholder="My Wasteland" + className="border-white/[0.08] bg-white/[0.03] text-sm text-white/85 placeholder:text-white/20" + /> + + + setUpstreamInput(e.target.value)} + placeholder="my-org/my-wasteland" + className="border-white/[0.08] bg-white/[0.03] font-mono text-sm text-white/85 placeholder:text-white/20" + /> + +
+ + + + + + )} + + {step === 'credentials' && ( + <> + + DoltHub Credentials + + {mode === 'create' + ? 'Enter DoltHub credentials with push access — these are used to create the repo and register the first rig.' + : 'Enter your DoltHub credentials. These are used to fork the commons and push contributions.'} + + +
+ + setDolthubToken(e.target.value)} + placeholder="Enter your DoltHub API token" + className="border-white/[0.08] bg-white/[0.03] font-mono text-sm text-white/85 placeholder:text-white/20" + /> + + + setDolthubOrg(e.target.value)} + placeholder="my-org" + className="border-white/[0.08] bg-white/[0.03] font-mono text-sm text-white/85 placeholder:text-white/20" + /> + + + setDoltCredsJwk(e.target.value)} + placeholder='{"kid":"...","kty":"OKP",...}' + className="border-white/[0.08] bg-white/[0.03] font-mono text-sm text-white/85 placeholder:text-white/20" + /> + + +
+ + + + + + )} + + {step === 'identity' && ( + <> + + Rig Identity + + Choose a handle for this town's rig on the commons. This identifies your + contributions. + + +
+ + setRigHandle(e.target.value)} + placeholder="kilo-my-town" + className="border-white/[0.08] bg-white/[0.03] font-mono text-sm text-white/85 placeholder:text-white/20" + /> + + + setDoltUserName(e.target.value)} + placeholder="Your Name" + className="border-white/[0.08] bg-white/[0.03] text-sm text-white/85 placeholder:text-white/20" + /> + + + setDoltUserEmail(e.target.value)} + placeholder="you@example.com" + className="border-white/[0.08] bg-white/[0.03] text-sm text-white/85 placeholder:text-white/20" + /> + + {error && ( +
+

{error}

+
+ )} +
+ + + + + + )} + + {step === 'connecting' && ( + <> + + + {mode === 'create' ? 'Creating...' : 'Connecting...'} + + + {mode === 'create' ? ( + <> + Bootstrapping{' '} + {upstreamInput.trim()} on + DoltHub and registering{' '} + {rigHandle} as the first rig. + + ) : ( + <> + Setting up credentials, forking the commons, and joining as{' '} + {rigHandle}. + + )} + + +
+ +

This may take a minute...

+
+ + )} + + {step === 'success' && ( + <> + + + {mode === 'create' ? 'Wasteland created' : 'Connected'} + + + {mode === 'create' + ? 'Your wasteland is live. Invite contributors from settings.' + : 'This town is now connected to the wasteland.'} + + +
+ +
+

+ {mode === 'create' ? 'Your wasteland is live at ' : 'Connected to '} + {connectedUpstream}{' '} + {rigHandle && ( + <> + as {rigHandle} + + )} +

+

+ {mode === 'create' + ? "You're the owner and admin. You can invite contributors from wasteland settings." + : 'Agents now have access to wasteland tools. Try asking the mayor to browse the wasteland.'} +

+
+
+ + + {connectedWastelandId && ( + + Visit wasteland + + + )} + + + )} +
+
+ ); +} + +// ── Shared ──────────────────────────────────────────────────────────────── + +function FieldGroup({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/OrgWastelandListPageClient.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/OrgWastelandListPageClient.tsx new file mode 100644 index 0000000000..5963ec5aed --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/OrgWastelandListPageClient.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { useWastelandTRPC } from '@/lib/wasteland/trpc'; +import { PageContainer } from '@/components/layouts/PageContainer'; +import { Button } from '@/components/Button'; +import { Badge } from '@/components/ui/badge'; +import { SetPageTitle } from '@/components/SetPageTitle'; +import { GastownBackdrop } from '@/components/gastown/GastownBackdrop'; +import { Plus, Skull } from 'lucide-react'; +import { + WastelandCard, + WastelandListSkeleton, +} from '@/app/(app)/wasteland/_components/WastelandListComponents'; + +type OrgWastelandListPageClientProps = { + organizationId: string; +}; + +export function OrgWastelandListPageClient({ organizationId }: OrgWastelandListPageClientProps) { + const router = useRouter(); + const trpc = useWastelandTRPC(); + const newUrl = `/organizations/${organizationId}/wasteland/new`; + + const wastelandsQuery = useQuery({ + ...trpc.wasteland.listWastelands.queryOptions({ organizationId }), + refetchInterval: 30_000, + }); + + const wastelands = wastelandsQuery.data ?? []; + const activeWastelands = wastelands.filter(w => w.status === 'active'); + + return ( + + +
+ + beta + +
+
+

+ A hosted bounty board backed by DoltHub. Post wanted items, claim work, and track + completions across your organization’s projects. +

+
+ + +
+ +
+
+
Wastelands
+
+ {wastelandsQuery.isLoading ? '…' : activeWastelands.length} +
+
+
+
Total
+
+ {wastelandsQuery.isLoading ? '…' : wastelands.length} +
+
+
+
Backed by
+
DoltHub
+
+
+
Scope
+
Organization
+
+
+
+
+ + {wastelandsQuery.isLoading && } + + {wastelandsQuery.data && wastelands.length === 0 && ( + +
+ +

No wastelands yet

+

+ Create a wasteland to set up a hosted bounty board for your organization. Connect it + to DoltHub to track wanted items, claims, and completions. +

+ +
+
+ )} + + {wastelands.length > 0 && ( +
+ {wastelands.map(wasteland => ( + + router.push(`/organizations/${organizationId}/wasteland/${wasteland.wasteland_id}`) + } + /> + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/claims/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/claims/page.tsx new file mode 100644 index 0000000000..ea805d39d3 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/claims/page.tsx @@ -0,0 +1,17 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { ClaimsClient } from '@/app/(app)/wasteland/[wastelandId]/claims/ClaimsClient'; + +export default async function OrgClaimsPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { wastelandId } = await params; + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/layout.tsx new file mode 100644 index 0000000000..f5fe0aef63 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/layout.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { WastelandTRPCProvider, createWastelandTRPCClient } from '@/lib/wasteland/trpc'; +import { HideAppTopbar } from '@/components/gastown/HideAppTopbar'; +import { WastelandDashboardHeader } from '@/app/(app)/wasteland/[wastelandId]/WastelandDashboardHeader'; + +export default function OrgWastelandLayout({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient(); + const [trpcClient] = useState(() => createWastelandTRPCClient()); + + return ( + + +
+ +
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/members/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/members/page.tsx new file mode 100644 index 0000000000..0ffde1a5ea --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/members/page.tsx @@ -0,0 +1,17 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { MembersClient } from '@/app/(app)/wasteland/[wastelandId]/members/MembersClient'; + +export default async function OrgMembersPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { wastelandId } = await params; + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/page.tsx new file mode 100644 index 0000000000..4d013e6eb0 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from 'next/navigation'; + +export default async function OrgWastelandDashboardPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { id, wastelandId } = await params; + redirect(`/organizations/${id}/wasteland/${wastelandId}/wanted`); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/review/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/review/page.tsx new file mode 100644 index 0000000000..806070583a --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/review/page.tsx @@ -0,0 +1,17 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { ReviewClient } from '@/app/(app)/wasteland/[wastelandId]/review/ReviewClient'; + +export default async function OrgReviewPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { wastelandId } = await params; + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/rigs/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/rigs/page.tsx new file mode 100644 index 0000000000..76d5a292f4 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/rigs/page.tsx @@ -0,0 +1,17 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { RigsClient } from '@/app/(app)/wasteland/[wastelandId]/rigs/RigsClient'; + +export default async function OrgRigsPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { wastelandId } = await params; + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/settings/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/settings/page.tsx new file mode 100644 index 0000000000..6d26cfab58 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/settings/page.tsx @@ -0,0 +1,17 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { SettingsClient } from '@/app/(app)/wasteland/[wastelandId]/settings/SettingsClient'; + +export default async function OrgSettingsPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { wastelandId } = await params; + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/wanted/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/wanted/page.tsx new file mode 100644 index 0000000000..13d9b3e5bf --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/[wastelandId]/wanted/page.tsx @@ -0,0 +1,17 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { WantedBoardClient } from '@/app/(app)/wasteland/[wastelandId]/wanted/WantedBoardClient'; + +export default async function OrgWantedBoardPage({ + params, +}: { + params: Promise<{ id: string; wastelandId: string }>; +}) { + const { wastelandId } = await params; + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/new/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/new/page.tsx new file mode 100644 index 0000000000..8724410765 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/new/page.tsx @@ -0,0 +1,23 @@ +import { notFound } from 'next/navigation'; +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { NewWastelandWizardClient } from '@/app/(app)/wasteland/new/NewWastelandWizardClient'; +import { getUserFromAuthOrRedirect } from '@/lib/user.server'; +import { isWastelandEnabled } from '@/lib/wasteland/feature-flags'; + +export default async function OrgNewWastelandPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const user = await getUserFromAuthOrRedirect( + `/users/sign_in?callbackPath=/organizations/${id}/wasteland/new` + ); + + if (!(await isWastelandEnabled(user.id, { isAdmin: user.is_admin }))) { + return notFound(); + } + + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/wasteland/page.tsx b/apps/web/src/app/(app)/organizations/[id]/wasteland/page.tsx new file mode 100644 index 0000000000..156ddc748d --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/wasteland/page.tsx @@ -0,0 +1,11 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { OrgWastelandListPageClient } from './OrgWastelandListPageClient'; + +export default async function OrgWastelandPage({ params }: { params: Promise<{ id: string }> }) { + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/wasteland/WastelandListPageClient.tsx b/apps/web/src/app/(app)/wasteland/WastelandListPageClient.tsx new file mode 100644 index 0000000000..0fa5a73d4d --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/WastelandListPageClient.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { useWastelandTRPC } from '@/lib/wasteland/trpc'; +import { PageContainer } from '@/components/layouts/PageContainer'; +import { Button } from '@/components/Button'; +import { Badge } from '@/components/ui/badge'; +import { SetPageTitle } from '@/components/SetPageTitle'; +import { GastownBackdrop } from '@/components/gastown/GastownBackdrop'; +import { Plus, Skull } from 'lucide-react'; +import { WastelandCard, WastelandListSkeleton } from './_components/WastelandListComponents'; + +export function WastelandListPageClient() { + const router = useRouter(); + const trpc = useWastelandTRPC(); + + const wastelandsQuery = useQuery({ + ...trpc.wasteland.listWastelands.queryOptions({}), + refetchInterval: 30_000, + }); + + const wastelands = wastelandsQuery.data ?? []; + const activeWastelands = wastelands.filter(w => w.status === 'active'); + + return ( + + +
+
+
+ + beta + +

+ A hosted bounty board backed by DoltHub. Post wanted items, claim work, and track + completions across your projects. +

+
+ + +
+ +
+
+
Wastelands
+
+ {wastelandsQuery.isLoading ? '…' : activeWastelands.length} +
+
+
+
Total
+
+ {wastelandsQuery.isLoading ? '…' : wastelands.length} +
+
+
+
Backed by
+
DoltHub
+
+
+
Scope
+
Personal
+
+
+
+
+ + {wastelandsQuery.isLoading && } + + {wastelandsQuery.data && wastelands.length === 0 && ( + +
+ +

No wastelands yet

+

+ Create a wasteland to set up a hosted bounty board. Connect it to DoltHub to track + wanted items, claims, and completions. +

+ +
+
+ )} + + {wastelands.length > 0 && ( +
+ {wastelands.map(wasteland => ( + router.push(`/wasteland/${wasteland.wasteland_id}`)} + /> + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandDashboardHeader.tsx b/apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandDashboardHeader.tsx new file mode 100644 index 0000000000..f560c77add --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandDashboardHeader.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { useWastelandTRPC } from '@/lib/wasteland/trpc'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { SidebarTrigger } from '@/components/ui/sidebar'; +import { Skull, Globe, Lock } from 'lucide-react'; +import { useWastelandPageHeader } from './WastelandPageHeaderContext'; + +const statusStyles: Record = { + active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', + deleted: 'bg-red-500/10 text-red-400 border-red-500/20', +}; + +export function WastelandDashboardHeader() { + const params = useParams<{ wastelandId: string }>(); + const wastelandId = params.wastelandId; + const trpc = useWastelandTRPC(); + + const wastelandQuery = useQuery(trpc.wasteland.getWasteland.queryOptions({ wastelandId })); + const wasteland = wastelandQuery.data; + // Page-specific section contributed by the active route via + // `useSetWastelandPageHeader`. May be null during loading or if a page + // hasn't written one (e.g. placeholder routes). + const pageHeader = useWastelandPageHeader(); + + return ( +
+
+ + +
+ +
+ + {wastelandQuery.isLoading ? ( +
+ + +
+ ) : ( +
+

+ {wasteland?.name ?? 'Wasteland'} +

+ {wasteland && ( + <> + + {wasteland.status} + + + {wasteland.visibility === 'public' ? ( + + ) : ( + + )} + {wasteland.visibility} + + + )} +
+ )} + + {/* Page-specific section — title + count + CTAs, right-aligned via flex-1. */} + {pageHeader && ( +
+
+ {pageHeader.icon} +

+ {pageHeader.title} +

+ {pageHeader.count != null && ( + {pageHeader.count} + )} +
+ {pageHeader.actions && ( +
{pageHeader.actions}
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandPageHeaderContext.tsx b/apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandPageHeaderContext.tsx new file mode 100644 index 0000000000..e11f6c9f66 --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/[wastelandId]/WastelandPageHeaderContext.tsx @@ -0,0 +1,144 @@ +'use client'; + +/** + * Per-wasteland-page header composition. + * + * The wasteland layout renders one top navbar (`WastelandDashboardHeader`). + * Each page contributes its own "section" (title + count + action buttons) + * into that navbar via `useSetWastelandPageHeader(section)` — mirroring + * the `SetPageTitle` / `PageTitleContext` pattern already used elsewhere + * in the app, but scoped to this subtree and shaped for the target DOM. + * + * Usage: + * useSetWastelandPageHeader({ + * title: 'Wanted Board', + * icon: , + * count: items.length, + * actions: <> + * + * + * , + * }); + * + * Implementation note: under the hood we use a mutable "latest section" + * ref + `useSyncExternalStore` rather than React state, so pages can + * pass inline JSX for `icon` and `actions` (fresh ReactNode identity + * every render) without triggering an infinite render loop. Pages + * publish the whole section every render; the navbar re-renders only + * when `title` or `count` change, or when the subscriber explicitly + * notifies (e.g. on mount/unmount). + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useSyncExternalStore, + type ReactNode, +} from 'react'; + +export type WastelandPageHeader = { + /** Plain-text page title shown beside the wasteland identity. */ + title: string; + /** Optional leading icon (already styled by the caller). */ + icon?: ReactNode; + /** Optional count rendered after the title (items, members, rigs…). */ + count?: number | null; + /** Optional right-aligned action cluster (buttons, dialog triggers, badges). */ + actions?: ReactNode; +}; + +type Store = { + get: () => WastelandPageHeader | null; + /** Replace the whole section (or clear it). Triggers subscribers. */ + publish: (next: WastelandPageHeader | null) => void; + /** Subscribe to header changes. Called by `useSyncExternalStore`. */ + subscribe: (listener: () => void) => () => void; +}; + +function createStore(): Store { + let current: WastelandPageHeader | null = null; + const listeners = new Set<() => void>(); + return { + get: () => current, + publish(next) { + current = next; + for (const l of listeners) l(); + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +const StoreContext = createContext(undefined); + +export function WastelandPageHeaderProvider({ children }: { children: ReactNode }) { + // One store per provider instance. Stable across re-renders. + const store = useMemo(() => createStore(), []); + return {children}; +} + +function useStore(): Store { + const store = useContext(StoreContext); + if (!store) { + throw new Error( + 'useWastelandPageHeader / useSetWastelandPageHeader must be used within a WastelandPageHeaderProvider' + ); + } + return store; +} + +/** + * Read the current page header section. Returns `null` when no page has + * written one yet (loading, or off-route). Consumed by + * `WastelandDashboardHeader` to render the third slot in the navbar. + * + * Uses `useSyncExternalStore` so the navbar re-renders only when the + * store publishes — not on every page render. + */ +export function useWastelandPageHeader(): WastelandPageHeader | null { + const store = useStore(); + return useSyncExternalStore(store.subscribe, store.get, store.get); +} + +/** + * Declaratively write the current page's header section. Clears on unmount + * so stale sections don't leak across page transitions. + * + * Safe to call with inline JSX for `icon` and `actions`: each render + * publishes the fresh section, the store forwards it to the navbar's + * `useSyncExternalStore` subscription, and the navbar re-renders once. + * This hook never subscribes to the store, so publishing from here does + * NOT re-render the caller — no infinite loop. + */ +export function useSetWastelandPageHeader(section: WastelandPageHeader): void { + const store = useStore(); + + // Keep a ref to the latest section so the unmount cleanup knows what + // we last wrote and can avoid clobbering a newer page's header. + const latest = useRef(section); + latest.current = section; + + // Publish on every render. Because `publish` only notifies subscribers + // (the navbar) and doesn't touch React state owned by *this* component, + // writing here doesn't re-trigger a render on the caller. + useEffect(() => { + store.publish(latest.current); + }); + + // Cleanup on unmount only. + const cleanup = useCallback(() => { + // If a newer writer has already replaced our section, leave it. + if (store.get() === latest.current) { + store.publish(null); + } + }, [store]); + useEffect(() => cleanup, [cleanup]); +} diff --git a/apps/web/src/app/(app)/wasteland/[wastelandId]/claims/ClaimsClient.tsx b/apps/web/src/app/(app)/wasteland/[wastelandId]/claims/ClaimsClient.tsx new file mode 100644 index 0000000000..9a6298a604 --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/[wastelandId]/claims/ClaimsClient.tsx @@ -0,0 +1,5 @@ +'use client'; + +export function ClaimsClient({ wastelandId }: { wastelandId: string }) { + return
Wasteland claims coming soon (wasteland: {wastelandId})
; +} diff --git a/apps/web/src/app/(app)/wasteland/[wastelandId]/claims/page.tsx b/apps/web/src/app/(app)/wasteland/[wastelandId]/claims/page.tsx new file mode 100644 index 0000000000..a5f2225910 --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/[wastelandId]/claims/page.tsx @@ -0,0 +1,17 @@ +import { getUserFromAuthOrRedirect } from '@/lib/user.server'; +import { notFound } from 'next/navigation'; +import { isWastelandEnabled } from '@/lib/wasteland/feature-flags'; +import { ClaimsClient } from './ClaimsClient'; + +export default async function ClaimsPage({ params }: { params: Promise<{ wastelandId: string }> }) { + const { wastelandId } = await params; + const user = await getUserFromAuthOrRedirect( + `/users/sign_in?callbackPath=/wasteland/${wastelandId}/claims` + ); + + if (!(await isWastelandEnabled(user.id, { isAdmin: user.is_admin }))) { + return notFound(); + } + + return ; +} diff --git a/apps/web/src/app/(app)/wasteland/[wastelandId]/layout.tsx b/apps/web/src/app/(app)/wasteland/[wastelandId]/layout.tsx new file mode 100644 index 0000000000..5f15d30d49 --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/[wastelandId]/layout.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { WastelandTRPCProvider, createWastelandTRPCClient } from '@/lib/wasteland/trpc'; +import { HideAppTopbar } from '@/components/gastown/HideAppTopbar'; +import { DrawerStackProvider } from '@/components/wasteland/drawer/WastelandDrawerStack'; +import { renderWastelandDrawerContent } from '@/components/wasteland/drawer/renderDrawerContent'; +import { WastelandDashboardHeader } from './WastelandDashboardHeader'; +import { WastelandPageHeaderProvider } from './WastelandPageHeaderContext'; + +export default function WastelandLayout({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient(); + const [trpcClient] = useState(() => createWastelandTRPCClient()); + + return ( + + + + +
+ +
{children}
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/wasteland/[wastelandId]/members/MembersClient.tsx b/apps/web/src/app/(app)/wasteland/[wastelandId]/members/MembersClient.tsx new file mode 100644 index 0000000000..5df5a5fa9f --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/[wastelandId]/members/MembersClient.tsx @@ -0,0 +1,563 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useWastelandTRPC } from '@/lib/wasteland/trpc'; +import type { WastelandOutputs } from '@/lib/wasteland/trpc'; +import { useUser } from '@/hooks/useUser'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { toast } from 'sonner'; +import { Users, Plus, Trash2, Pencil, Star } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { useSetWastelandPageHeader } from '../WastelandPageHeaderContext'; + +type WastelandMember = WastelandOutputs['wasteland']['listMembers'][number]; + +const ROLE_STYLES: Record = { + owner: 'bg-amber-500/10 text-amber-400 border-amber-500/20', + maintainer: 'bg-violet-500/10 text-violet-400 border-violet-500/20', + contributor: 'bg-sky-500/10 text-sky-400 border-sky-500/20', +}; + +function TrustStars({ level }: { level: number }) { + return ( + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ); +} + +export function MembersClient({ wastelandId }: { wastelandId: string }) { + const trpc = useWastelandTRPC(); + const queryClient = useQueryClient(); + const { data: currentUser } = useUser(); + + const membersQuery = useQuery(trpc.wasteland.listMembers.queryOptions({ wastelandId })); + const members = membersQuery.data ?? []; + + const currentUserMember = members.find(m => m.user_id === currentUser?.id); + const isOwnerOrAdmin = currentUserMember?.role === 'owner' || currentUser?.is_admin === true; + + const memberQueryKey = trpc.wasteland.listMembers.queryKey({ wastelandId }); + + useSetWastelandPageHeader({ + title: 'Members', + icon: , + count: members.length, + actions: isOwnerOrAdmin ? ( + + ) : null, + }); + + return ( +
+ {/* Table */} +
+ {membersQuery.isLoading && } + + {!membersQuery.isLoading && members.length === 0 && ( +
+ +

No members yet.

+
+ )} + + {!membersQuery.isLoading && members.length > 0 && ( + + + + User + Role + Trust + Joined + {isOwnerOrAdmin && ( + Actions + )} + + + + {members.map(member => ( + + ))} + +
+ )} +
+
+ ); +} + +// ── Member Row ─────────────────────────────────────────────────────────── + +function MemberRow({ + member, + wastelandId, + isOwnerOrAdmin, + isSelf, + trpc, + queryClient, + memberQueryKey, +}: { + member: WastelandMember; + wastelandId: string; + isOwnerOrAdmin: boolean; + isSelf: boolean; + trpc: ReturnType; + queryClient: ReturnType; + memberQueryKey: readonly unknown[]; +}) { + return ( + + + {member.user_id} + + + + {member.role} + + + + + + + {formatTimestamp(member.joined_at)} + + {isOwnerOrAdmin && ( + +
+ + {!isSelf && ( + + )} +
+
+ )} +
+ ); +} + +// ── Add Member Dialog ──────────────────────────────────────────────────── + +function AddMemberDialog({ + wastelandId, + trpc, + queryClient, + memberQueryKey, +}: { + wastelandId: string; + trpc: ReturnType; + queryClient: ReturnType; + memberQueryKey: readonly unknown[]; +}) { + const [open, setOpen] = useState(false); + const [userId, setUserId] = useState(''); + const [role, setRole] = useState<'contributor' | 'maintainer' | 'owner'>('contributor'); + const [trustLevel, setTrustLevel] = useState('1'); + + const addMember = useMutation({ + ...trpc.wasteland.addMember.mutationOptions(), + onSuccess: () => { + toast.success('Member added'); + void queryClient.invalidateQueries({ queryKey: memberQueryKey }); + setOpen(false); + setUserId(''); + setRole('contributor'); + setTrustLevel('1'); + }, + onError: err => toast.error(`Failed to add member: ${err.message}`), + }); + + return ( + + + + + + + Add Member + + Add a user by their ID. They will gain access to this wasteland. + + + +
+
+ + setUserId(e.target.value)} + placeholder="Enter user ID" + className="border-white/[0.08] bg-white/[0.03] font-mono text-sm text-white/85 placeholder:text-white/20" + /> +
+ +
+ + +
+ +
+ + +
+
+ + + + + +
+
+ ); +} + +// ── Edit Member Dialog ─────────────────────────────────────────────────── + +function EditMemberDialog({ + member, + wastelandId, + trpc, + queryClient, + memberQueryKey, +}: { + member: WastelandMember; + wastelandId: string; + trpc: ReturnType; + queryClient: ReturnType; + memberQueryKey: readonly unknown[]; +}) { + const [open, setOpen] = useState(false); + const [role, setRole] = useState(member.role); + const [trustLevel, setTrustLevel] = useState(String(member.trust_level)); + + const updateMember = useMutation({ + ...trpc.wasteland.updateMember.mutationOptions(), + onSuccess: () => { + toast.success('Member updated'); + void queryClient.invalidateQueries({ queryKey: memberQueryKey }); + setOpen(false); + }, + onError: err => toast.error(`Failed to update member: ${err.message}`), + }); + + return ( + { + setOpen(isOpen); + if (isOpen) { + setRole(member.role); + setTrustLevel(String(member.trust_level)); + } + }} + > + + + + + + Edit Member + + Update role and trust level for{' '} + {member.user_id} + + + +
+
+ + +
+ +
+ + +
+
+ + + + + +
+
+ ); +} + +// ── Remove Member Dialog ───────────────────────────────────────────────── + +function RemoveMemberDialog({ + member, + wastelandId, + trpc, + queryClient, + memberQueryKey, +}: { + member: WastelandMember; + wastelandId: string; + trpc: ReturnType; + queryClient: ReturnType; + memberQueryKey: readonly unknown[]; +}) { + const removeMember = useMutation({ + ...trpc.wasteland.removeMember.mutationOptions(), + onSuccess: () => { + toast.success('Member removed'); + void queryClient.invalidateQueries({ queryKey: memberQueryKey }); + }, + onError: err => toast.error(`Failed to remove member: ${err.message}`), + }); + + return ( + + + + + + + Remove member? + + This will remove {member.user_id} from + this wasteland. They will lose all access. + + + + + Cancel + + + removeMember.mutate({ + wastelandId, + memberId: member.member_id, + }) + } + className="bg-red-500/80 text-white hover:bg-red-500" + > + {removeMember.isPending ? 'Removing...' : 'Remove'} + + + + + ); +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +function formatTimestamp(iso: string): string { + try { + return formatDistanceToNow(new Date(iso), { addSuffix: true }); + } catch { + return iso; + } +} + +function MembersTableSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + + +
+ ))} +
+ ); +} diff --git a/apps/web/src/app/(app)/wasteland/[wastelandId]/members/page.tsx b/apps/web/src/app/(app)/wasteland/[wastelandId]/members/page.tsx new file mode 100644 index 0000000000..d2a9bc7511 --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/[wastelandId]/members/page.tsx @@ -0,0 +1,21 @@ +import { getUserFromAuthOrRedirect } from '@/lib/user.server'; +import { notFound } from 'next/navigation'; +import { isWastelandEnabled } from '@/lib/wasteland/feature-flags'; +import { MembersClient } from './MembersClient'; + +export default async function MembersPage({ + params, +}: { + params: Promise<{ wastelandId: string }>; +}) { + const { wastelandId } = await params; + const user = await getUserFromAuthOrRedirect( + `/users/sign_in?callbackPath=/wasteland/${wastelandId}/members` + ); + + if (!(await isWastelandEnabled(user.id, { isAdmin: user.is_admin }))) { + return notFound(); + } + + return ; +} diff --git a/apps/web/src/app/(app)/wasteland/[wastelandId]/page.tsx b/apps/web/src/app/(app)/wasteland/[wastelandId]/page.tsx new file mode 100644 index 0000000000..3e3439fce9 --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/[wastelandId]/page.tsx @@ -0,0 +1,20 @@ +import { getUserFromAuthOrRedirect } from '@/lib/user.server'; +import { notFound, redirect } from 'next/navigation'; +import { isWastelandEnabled } from '@/lib/wasteland/feature-flags'; + +export default async function WastelandDashboardPage({ + params, +}: { + params: Promise<{ wastelandId: string }>; +}) { + const { wastelandId } = await params; + const user = await getUserFromAuthOrRedirect( + `/users/sign_in?callbackPath=/wasteland/${wastelandId}` + ); + + if (!(await isWastelandEnabled(user.id, { isAdmin: user.is_admin }))) { + return notFound(); + } + + redirect(`/wasteland/${wastelandId}/wanted`); +} diff --git a/apps/web/src/app/(app)/wasteland/[wastelandId]/review/ReviewClient.tsx b/apps/web/src/app/(app)/wasteland/[wastelandId]/review/ReviewClient.tsx new file mode 100644 index 0000000000..31278ec667 --- /dev/null +++ b/apps/web/src/app/(app)/wasteland/[wastelandId]/review/ReviewClient.tsx @@ -0,0 +1,703 @@ +'use client'; + +/** + * Review inbox — typed list of open upstream pull requests. + * + * Layout mirrors the Wanted Board: dense one-line list on the left, + * slide-over detail panel on the right, search + kind filter + sort + * toolbar at the top. Each PR is classified server-side into one of + * five kinds (plus `unknown` for foreign PRs) and rendered with kind- + * specific metadata both in the row and in the drawer body. + * + * Gating: only wasteland owners with admin mode enabled can load this + * page. Contributors (no credential, or credential without + * `is_upstream_admin`) get a permission-denied notice. + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useWastelandTRPC } from '@/lib/wasteland/trpc'; +import type { WastelandOutputs } from '@/lib/wasteland/trpc'; +import { useUser } from '@/hooks/useUser'; +import { useSetWastelandPageHeader } from '../WastelandPageHeaderContext'; +import { useDrawerStack } from '@/components/wasteland/drawer/WastelandDrawerStack'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { toast } from 'sonner'; +import { ArrowUpDown, CheckCircle2, Inbox, Loader2, Search, ShieldCheck } from 'lucide-react'; +import { AnimatePresence, motion } from 'motion/react'; +import { formatDistanceToNow } from 'date-fns'; + +type InboxItem = WastelandOutputs['wasteland']['listInboxItems']['items'][number]; +type InboxKind = InboxItem['kind']; +type SortField = 'activity' | 'kind'; + +const KIND_LABEL: Record = { + 'rig-registration': 'Registration', + 'wanted-post': 'New post', + 'wanted-edit': 'Edit', + 'work-submission': 'Submission', + 'admin-action': 'Admin action', + unknown: 'Foreign', +}; + +// Short-form label used in the row's trailing kind chip. +const KIND_SHORT: Record = { + 'rig-registration': 'rig', + 'wanted-post': 'post', + 'wanted-edit': 'edit', + 'work-submission': 'submission', + 'admin-action': 'admin', + unknown: 'foreign', +}; + +const KIND_DOT: Record = { + 'rig-registration': 'bg-emerald-400', + 'wanted-post': 'bg-sky-400', + 'wanted-edit': 'bg-amber-400', + 'work-submission': 'bg-violet-400', + 'admin-action': 'bg-indigo-400', + unknown: 'bg-white/20', +}; + +const KIND_CHIP: Record = { + 'rig-registration': 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', + 'wanted-post': 'bg-sky-500/10 text-sky-400 border-sky-500/20', + 'wanted-edit': 'bg-amber-500/10 text-amber-400 border-amber-500/20', + 'work-submission': 'bg-violet-500/10 text-violet-400 border-violet-500/20', + 'admin-action': 'bg-indigo-500/10 text-indigo-400 border-indigo-500/20', + unknown: 'bg-white/[0.04] text-white/40 border-white/10', +}; + +// Stable order for filter chips (matches the taxonomy docs). +const KIND_ORDER: InboxKind[] = [ + 'rig-registration', + 'wanted-post', + 'wanted-edit', + 'work-submission', + 'admin-action', + 'unknown', +]; + +export function ReviewClient({ wastelandId }: { wastelandId: string }) { + const trpc = useWastelandTRPC(); + const queryClient = useQueryClient(); + const { data: currentUser } = useUser(); + const { open: openDrawer, closeAll: closeDrawer } = useDrawerStack(); + + const wastelandQuery = useQuery(trpc.wasteland.getWasteland.queryOptions({ wastelandId })); + const credentialQuery = useQuery( + trpc.wasteland.getCredentialStatus.queryOptions({ wastelandId }) + ); + const membersQuery = useQuery(trpc.wasteland.listMembers.queryOptions({ wastelandId })); + + const isUpstreamAdmin = credentialQuery.data?.is_upstream_admin === true; + const currentUserMember = membersQuery.data?.find(m => m.user_id === currentUser?.id); + const isOwner = currentUserMember?.role === 'owner' || currentUser?.is_admin === true; + + const inboxQueryKey = trpc.wasteland.listInboxItems.queryKey({ wastelandId }); + const inboxQuery = useQuery({ + ...trpc.wasteland.listInboxItems.queryOptions({ wastelandId }), + enabled: isOwner && isUpstreamAdmin, + refetchInterval: 30_000, + }); + + // ── Filter / sort state ────────────────────────────────────────── + const [search, setSearch] = useState(''); + const [kindFilter, setKindFilter] = useState(null); + const [sortField, setSortField] = useState('activity'); + const [commentOnItem, setCommentOnItem] = useState(null); + + const items = useMemo(() => inboxQuery.data?.items ?? [], [inboxQuery.data]); + + const counts = useMemo(() => countByKind(items), [items]); + + const filteredItems = useMemo(() => { + const query = search.trim().toLowerCase(); + const filtered = items.filter(item => { + if (kindFilter && item.kind !== kindFilter) return false; + if (!query) return true; + return itemSearchHaystack(item).includes(query); + }); + return filtered.sort((a, b) => { + if (sortField === 'kind') { + const ak = KIND_ORDER.indexOf(a.kind); + const bk = KIND_ORDER.indexOf(b.kind); + if (ak !== bk) return ak - bk; + } + // Fall back to activity (newest first). + return timestampMs(b.updated_at) - timestampMs(a.updated_at); + }); + }, [items, search, kindFilter, sortField]); + + // ── Mutations ───────────────────────────────────────────────────── + const refetch = () => { + void queryClient.invalidateQueries({ queryKey: inboxQueryKey }); + }; + + // DoltHub merges async. Schedule 4 invalidations over ~30s so the row + // disappears as soon as the merge lands server-side without requiring + // a manual refresh. Timers are tracked in a ref so they can be + // cancelled on unmount. + const pendingTimers = useRef[]>([]); + useEffect( + () => () => { + for (const id of pendingTimers.current) clearTimeout(id); + pendingTimers.current = []; + }, + [] + ); + + const mergeMutation = useMutation({ + ...trpc.wasteland.mergeUpstreamPR.mutationOptions(), + onSuccess: () => { + toast.success('Merge initiated'); + closeDrawer(); + const refetchAt = [2_000, 5_000, 15_000, 30_000]; + for (const ms of refetchAt) { + pendingTimers.current.push(setTimeout(refetch, ms)); + } + }, + onError: err => toast.error(`Merge failed: ${err.message}`), + }); + + const closeMutation = useMutation({ + ...trpc.wasteland.closeUpstreamPR.mutationOptions(), + onSuccess: () => { + toast.success('PR closed'); + closeDrawer(); + refetch(); + }, + onError: err => toast.error(`Close failed: ${err.message}`), + }); + + const busy = mergeMutation.isPending || closeMutation.isPending; + + // ── Page header contribution ────────────────────────────────────── + useSetWastelandPageHeader({ + title: 'Review', + icon: , + count: isOwner && isUpstreamAdmin && inboxQuery.data ? items.length : null, + actions: isUpstreamAdmin ? ( + + + Admin view + + ) : null, + }); + + // ── Permission / loading states ─────────────────────────────────── + if (wastelandQuery.isLoading || credentialQuery.isLoading || membersQuery.isLoading) { + return {}; + } + + if (!isOwner) { + return ( + + + + ); + } + + if (!isUpstreamAdmin) { + return ( + + + + ); + } + + const upstream = wastelandQuery.data?.dolthub_upstream ?? null; + + const handleOpenItem = (item: InboxItem) => { + openDrawer({ + type: 'review-item', + wastelandId, + item, + actions: { + upstream, + busy, + onMerge: pr => mergeMutation.mutate({ wastelandId, pullId: pr.pull_id }), + onCloseAction: pr => closeMutation.mutate({ wastelandId, pullId: pr.pull_id }), + onComment: pr => setCommentOnItem(pr), + }, + }); + }; + + return ( +
+ {/* Main list */} +
+ {/* Toolbar */} +
+ {/* Search */} +
+ + setSearch(e.target.value)} + className="w-56 bg-transparent text-xs text-white/80 outline-none placeholder:text-white/25" + /> +
+ + {/* Kind filter chips */} +
+ setKindFilter(null)} + /> + {KIND_ORDER.map(kind => { + const count = counts[kind] ?? 0; + if (count === 0) return null; + return ( + setKindFilter(kindFilter === kind ? null : kind)} + dotColor={KIND_DOT[kind]} + /> + ); + })} +
+ + {/* Sort toggle */} + +
+ + {/* Item list */} +
+ {inboxQuery.isLoading && } + + {inboxQuery.isError && !inboxQuery.isLoading && ( +
+

Failed to load inbox

+

{inboxQuery.error.message}

+
+ )} + + {!inboxQuery.isLoading && !inboxQuery.isError && items.length === 0 && } + + {!inboxQuery.isLoading && + !inboxQuery.isError && + items.length > 0 && + filteredItems.length === 0 && ( +
+ +

No inbox items match your filters.

+
+ )} + + + {filteredItems.map((item, i) => ( + handleOpenItem(item)} + className="group flex cursor-pointer items-center gap-3 border-b border-white/[0.04] px-6 py-2.5 transition-colors hover:bg-white/[0.02]" + > + +
+
+ {rowTitle(item)} + + {KIND_SHORT[item.kind]} + + #{item.pull_id} +
+
+ {rowSubtitle(item)} + {item.submitter && ( + <> + | + {item.submitter} + + )} + {item.updated_at && ( + <> + | + {formatRelative(item.updated_at)} + + )} +
+
+ + {rowAccent(item)} + +
+ ))} +
+
+
+ + setCommentOnItem(null)} + /> +
+ ); +} + +// ── Shell (used for loading / access-denied states) ───────────────────── + +function ReviewShell({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function InboxListSkeleton() { + return ( +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +function EmptyInbox() { + return ( +
+ +

Inbox zero

+

No open pull requests on the upstream.

+
+ ); +} + +function AccessDenied({ title, description }: { title: string; description: string }) { + return ( +
+ +

{title}

+

{description}

+
+ ); +} + +// ── Filter chip ────────────────────────────────────────────────────────── + +function FilterChip({ + label, + count, + active, + onClick, + dotColor, +}: { + label: string; + count: number; + active: boolean; + onClick: () => void; + dotColor?: string; +}) { + return ( + + ); +} + +// ── Row-title label maps ──────────────────────────────────────────────── +// +// These two const maps are also defined inside ReviewItemPanel.tsx for use +// by the drawer body. They're duplicated here intentionally because the +// row-title formatters (`rowTitle`, `rowAccent`) need them too, and +// importing from a sibling panel component would couple the row rendering +// to the drawer-internal structure. + +const EDIT_SUBKIND_LABEL: Record<'update' | 'delete' | 'unclaim', string> = { + update: 'Update', + delete: 'Withdraw', + unclaim: 'Unclaim', +}; + +const ADMIN_SUBKIND_LABEL: Record< + 'accept' | 'accept-upstream' | 'reject' | 'close' | 'close-upstream', + { label: string; tone: 'emerald' | 'red' | 'white' } +> = { + accept: { label: 'Accept + stamp', tone: 'emerald' }, + 'accept-upstream': { label: 'Accept upstream + stamp', tone: 'emerald' }, + reject: { label: 'Reject', tone: 'red' }, + close: { label: 'Close (no stamp)', tone: 'white' }, + 'close-upstream': { label: 'Close upstream (no stamp)', tone: 'white' }, +}; + +// ── Comment dialog ────────────────────────────────────────────────────── + +function CommentDialog({ + wastelandId, + item, + onClose, +}: { + wastelandId: string; + item: InboxItem | null; + onClose: () => void; +}) { + const trpc = useWastelandTRPC(); + const [comment, setComment] = useState(''); + + const commentMutation = useMutation({ + ...trpc.wasteland.commentOnUpstreamPR.mutationOptions(), + onSuccess: () => { + toast.success('Comment posted to DoltHub'); + setComment(''); + onClose(); + }, + onError: err => toast.error(`Comment failed: ${err.message}`), + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!item || !comment.trim()) return; + commentMutation.mutate({ + wastelandId, + pullId: item.pull_id, + comment: comment.trim(), + }); + }; + + const handleClose = () => { + setComment(''); + onClose(); + }; + + return ( + { + if (!open) handleClose(); + }} + > + + + Comment on PR + + Posts a comment to DoltHub on this pull request. The contributor will see it in the PR's + comment thread. + + + + {item && ( +
+

{item.title}

+

PR #{item.pull_id}

+
+ )} + +
+