diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46c94ad2..aa5f67a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,8 +89,24 @@ jobs: redis: image: redis:7-alpine ports: ["6379:6379"] + minio: + # Daily-workflow coverage uploads and previews real attachments. + image: bitnamilegacy/minio:latest + env: + MINIO_ROOT_USER: forgeminio + MINIO_ROOT_PASSWORD: forgeminio-dev-password + ports: ["9000:9000"] + options: >- + --health-cmd="mc ready local || curl -f http://localhost:9000/minio/health/live" + --health-interval=5s --health-timeout=5s --health-retries=20 env: # E2E harness (see scripts/e2e-web.sh + playwright.config.ts). + S3_ENDPOINT: http://localhost:9000 + S3_PUBLIC_ENDPOINT: http://localhost:9000 + S3_REGION: us-east-1 + S3_ACCESS_KEY: forgeminio + S3_SECRET_KEY: forgeminio-dev-password + S3_FORCE_PATH_STYLE: "true" E2E_MANAGE_STACK: "0" # use the service containers above, not docker-compose E2E_PORT: "3200" FORGE_E2E: "1" # mock-runs connector + seed fixtures diff --git a/DEVLOG.md b/DEVLOG.md index 73a6cc02..407b6757 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,134 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-10-15 — AXI-206 release preparation + +- Operator approved publication and deployment of the integrated AXI-203, + AXI-204 and AXI-206 work as v0.36.0. The separate AXI-191 architecture work + remains outside this release. Stopped all three local preview servers. +- Fixed Windows local-runner parsing for npm-generated pnpm launchers using + `%dp0%`; the launcher regression suite passed all seven tests. +- Added MinIO and explicit local S3 endpoints to each CI browser-test shard + because daily-workflow acceptance now uploads and previews real files. +- Updated navigation/setup browser regressions for Home, Agents and workspace + access. Kept an explicit Assign an agent action in the automation menu so + unassigned issues still support touch-based assignment, not only a shortcut. +- Production remains unchanged until exact-head CI and the coordinated + app/worker ownership-migration rollout complete. +- First release-candidate CI passed lint, typecheck, all 1,786 tests (one + intentionally skipped), and browser shard 1. Shard 2 exposed two assertions + selecting hidden responsive copies rather than the visible issue heading + and desktop admin scope label; corrected those selectors without weakening + the workflow assertions. Windows Compose text checks now normalize CRLF. +- Local full quality passed lint/typecheck and 1,784 tests, with two failures: + the corrected CRLF assertion and a diagnostic-worker readiness timeout that + does not reproduce in Linux CI. The final source typecheck/focused lint and + automation/Compose focused checks passed; final-head hosted CI remains the + release gate. +- The final mobile assignment flow exposed actual activity-dock overlap at the + end of short issue pages. Added mobile scroll clearance using the existing + spacing scale; the browser regression retains ordinary pointer clicks and + captures the expanded automation menu as rendered evidence. The diagnostic + worker suite subsequently passed locally unchanged (3/3); no readiness + timeout was increased. + +## 2026-10-15 — AXI-206 integrated daily workflow and issue context + +- Preserved local ownership/navigation work in checkpoint dc5c5c4 and integrated + AXI-203 capture + breadcrumb/centered pins and AXI-204 private reminders through + local cherry-picks (a8a3b99, 7b7a70a, 653dfe4). Only the development-log merge + needed manual reconciliation; both histories were retained. Main and origin + are untouched; no older release/recovery branches were included. +- Promoted issue title, due date and reminder to the header. Attachments now + appear below the description with visible filenames/thumbnails, Add file, + preview and bounded expansion. Related work stays visible with typed links, + statuses, parent/subtask context and expandable dependency graph. +- Added permanent Automation & delivery entry with explicit Ask, native + implementation PR linking, and delivery claim controls. Existing summaries + and warnings stay visible; evidence expands separately. Ask drafts a comment + with the selected mode and never dispatches or assigns just by opening it. + Existing draft modes/overrides for that agent are reconciled explicitly. +- Moved activity into the main column with Discussion, Updates and All filters, + ID-based comment/event deduplication and expandable routine-change groups. + Open decisions remain above the description independent of filters. Legacy + section links and 1/2/3 shortcuts focus the visible sections; mobile header + links keep automation and related work reachable above long timelines. +- Integrated due reminders into Home as well as Today and notification drawers. + Fixed the workspace experience-save baseline/race exposed by fast Personal → + Team → Personal switching. Realtime relation refresh covers reciprocal edges + and linked-title/status updates without creating extra activity or agent runs. +- Local validation uses the isolated forge_206_dev preview on port 3002 plus + guarded forge_test/Redis/MinIO for integration tests. Full release gates and + production deployment remain deferred pending operator iteration. +- Verification: 59 focused unit tests, 13 real reminder/notification integration + tests, TypeScript and focused ESLint passed (existing workspace native-select + warnings remain). A final single browser run passed all six scenarios: + capture/draft fields, status+manual delivery, private reminders, populated + files/relations/timeline, Ask mode reconciliation without dispatch, and + Personal/Team daily flow. Desktop/390px screenshots were inspected. Local + storage-backed browser checks allow bounded extra time for dev compilation; + this is functional proof, not a production performance certification. + +## 2026-10-15 — AXI-206 explicit ownership and runtime lifecycle + +- Added PERSONAL/SERVICE ownership for agent definitions and runtimes, separate + immutable creator provenance, explicit UNASSIGNED legacy runtimes, owner-shape + checks and service-key uniqueness. Personal owner deletion is restricted; + deleting a service resource's creator does not delete the service identity. +- Added audited personal creation policy (disabled / approval required / + self-service), defaulting to approval required. Self-service creates only a + private personal identity; workspace access and credentials remain separate. +- Added admin ownership transfer with optimistic snapshots and runtime consent, + plus runtime impact/prepare/transfer/removal workflows. Active work, unsafe + configuration and historical tenant references are protected; external hosts + are never stopped by removal. Archived registrations remain discoverable. +- Unified browser/MCP management checks and locked attachment, binding, + diagnostic and child-config mutations against stale ownership/home races. + An issuing human does not lend authority to a service key. New runtime + attachment requires explicit adoption of UNASSIGNED registrations; existing + legacy bindings remain operational pending deliberate operator action. +- Applied migration only to guarded local test DB and a separate AXI-206 + preview database copied from existing local data. Earlier previews and + production were not migrated. Verified real Postgres/Redis tests for policy, + transfer races, ownership constraints, scope isolation and compatibility; + focused UI tests and TypeScript/lint also passed. Shared-dispatcher suites + require sequential execution to avoid cross-suite pending-run interference. +- Browser-verified ordinary member self-service creation, admin service-agent + creation and transfer, and runtime service ownership/home relocation followed + by removal of a disposable unused registration. Mobile confirmation has no + horizontal overflow. Fixed modal foreground inheritance from admin chrome and + removed a stale impact-query refetch after successful registration deletion. +- No commit/push/PR/release/deploy. Full release gates remain deferred. Documented + the coordinated migration/code rollout required before future production use. + +## 2026-10-14 — AXI-206 navigation and agent setup local iteration + +- Split personal Home/My Work from Mission Control operations; added a global + Workspaces directory with a compatibility redirect from account settings. + Clarified My account, Administration and workspace scope labels. +- Added intent-first external MCP versus managed-execution setup with workspace + and acting-identity continuity. Agent pages distinguish owner, catalog + availability, workspace access, configured runtime and valid client keys. + New profiles default owner-only; new keys default Standard scopes. Existing + records and the instance-admin approval requirement remain unchanged. +- Unified agent availability gates and removed ownership-based cross-workspace + run/client visibility. Project restrictions apply to recent activity. Public + profile/binding results redact dispatch secrets, including mutation results. +- Guarded permanent workspace deletion while runtime registrations exist, + including archived/unbound hosts, with real database locking. Bucket cleanup + retains the existing slug reservation until deletion; its pre-existing + non-atomic storage/database limitation is documented. +- Verified 20 agent-access integration tests, 18 runtime/storage integration + tests, 18 focused UI/setup/operations unit tests, TypeScript and focused lint. + Real-browser walkthroughs on localhost:3002 covered Home, agent setup and + credential-form context, workspace redirect and operations; desktop and + 390px mobile Home/Agents layouts have no horizontal overflow. No real external + runtime was dispatched or credential issued during browser checks. +- Local iteration only: no commit, push, PR, release, schema migration or deploy. + Service-owned identities, explicit runtime relocation/removal and configurable + personal-agent creation remain follow-up model work. Full release gates are + deferred until the operator accepts the local iteration. + ## 2026-09-01 — v0.35.0 release preparation - Squash-merged AXI-186 / PR #102 after exact-head lint, typecheck, unit, @@ -14618,3 +14746,84 @@ publishes those prevalidated assets after tagging. Verification: focused runtime-diagnostic, MCP-execution, and helper-distribution tests passed (11/11); TypeScript passed; lint completed with only the existing repository warnings; helper tarballs and `SHA256SUMS` verified successfully. + +## 2026-09-25 — AXI-203 New Item and personal-task capture (in progress) + +Explicit New issue/New task controls now carry their destination into quick +create, so an issue-detail route cannot silently turn them into a comment. +Personal workspace capture and the Today composer persist `TASK` work items; +note-to-issue conversion remains an explicit issue. Quick create preserves Tab +focus navigation while Enter accepts autocomplete suggestions. Today separates +due-today items from unscheduled Someday work, exposes the due preset on mobile, +and links to the full Issues list instead of presenting its summary as the +entire task inventory. Quickstart and Issues guidance now describe the actual +capture behavior. + +First-class per-user reminders need a separate model and worker path rather +than the existing ScheduledTask create-issue automation; tracked in AXI-204. +Focused quick-create unit tests, TypeScript, and lint passed. The local Docker +engine and production browser sign-in were unavailable, so local Playwright, +integration checks, and rendered desktop/mobile UX review remain pending. + +## 2026-10-14 — AXI-203 local creation-flow review and verification + +Replaced the dense capture bar with one item-type selector, visible title and +description, direct status/priority/project/human-assignee/date controls, and +expandable agent/label options. Keyboard slash shortcuts remain available. +Explicit named creation controls preserve their intent through lazy loading, +including project actions on detail pages. Task creation uses the existing +TASK kind, now labeled Task whether standalone or nested. Draft restoration +retains content and selected fields within the workspace/issue context, and +planning-item full forms preserve the entered name. Date selection persists +atomically with creation. Fixed a shared Combobox callback that updated its +parent from inside a state updater. + +Personal Today keeps undated work in Someday, exposes date presets on mobile, +and makes its bounded recent-work summary explicit. The full reminders feature +remains AXI-204; due dates and issue snoozing do not imply reminders. + +Verification: focused lint and 12 quick-create unit checks passed. A production +build and three focused Playwright flows passed against disposable forge_e2e, +covering draft restore, saved TASK fields, named issue creation, keyboard focus, +status transitions, and mobile Today/Someday behavior. Captured and inspected +desktop, dark, and mobile UI evidence under output/playwright. The local preview +uses Node 20 with the standard Next compiler: Turbopack reproduced an existing +recordChange import failure on creation, and the workstation's Node 24 also +failed loading the Tailwind TypeScript config through the alternate compiler. +No production deployment or release was performed. + +### AXI-203 header follow-up + +Issue breadcrumbs now use the workspace context key and show the issue title; +the full identity remains available through the tooltip/accessibility label +when the title truncates. Removed the unused route-parser key that produced +bare numbers such as `-20`. The desktop header uses equal side columns so pins +stay centered independently of breadcrumb length, with responsive pin widths +and compact actions at smaller sizes. Focused lint passed; the rendered header +was checked at 1764, 1280, 1024, and 768px with centered pins and no overflow. +## 2026-10-14 — AXI-204 personal issue reminders + +Added a private one-shot IssueReminder per user/item with an additive timestamped +migration. The issue sidebar supports scheduling, rescheduling, presets, and +cancellation. Due cards appear in workspace/global notifications and personal +Today with issue key/title, open, snooze, and dismiss actions. Notification badges +and mark-read behavior include reminders without removing their actionable cards. +Due dates, issue snoozing, and recurring issue automation keep their existing +meaning. + +Membership, account lifecycle, and project access are checked on reads and push +claims. Completion and archival cancel reminders in the canonical audit path; +hard deletion cascades. Personal mutations write audit records without creating +workspace activity events, comments, decision requests, or LLM wakeups. The +maintenance sweep atomically claims optional browser push once per schedule; +in-app cards remain durable when workers or browser delivery are unavailable. + +Verification: migration applied to guarded local databases; focused lint and +TypeScript passed; 52 issue/notification/reminder integration checks passed, +including concurrent schedule/push claims, tenant/recipient/project isolation, +read/snooze/dismiss, completion, archive, and suspended-user cases. Production +build and three focused Playwright flows passed, including the new reminder +lifecycle and mobile notification card. Screenshots were inspected under +output/playwright. Actual external browser push was not exercised because local +VAPID/subscriptions are not configured; that channel remains explicitly best +effort. No production migration, deployment, or release was performed. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index b7831345..62092022 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -122,6 +122,7 @@ export default defineConfig({ { text: "Today Widget", link: "/guide/today-widget.html" }, { text: "What's New", link: "/guide/whats-new.html" }, { text: "Issues", link: "/guide/issues.html" }, + { text: "Personal reminders", link: "/guide/reminders.html" }, { text: "Slash Commands", link: "/guide/slash-commands.html" }, { text: "Watching", link: "/guide/watching.html" }, { text: "Quick Notes", link: "/guide/quick-notes.html" }, diff --git a/docs/guide/instance-admin.md b/docs/guide/instance-admin.md index e88112ed..d1d2b7c2 100644 --- a/docs/guide/instance-admin.md +++ b/docs/guide/instance-admin.md @@ -1,5 +1,8 @@ # Instance Admin +For the distinction between Home, Mission Control, Agents, My account and +Administration, see [Navigation and agent setup](./navigation-and-agent-setup.md). + `/admin` is the instance-operator surface — the controls that span _every_ tenant on a Forge instance, rather than one workspace. It renders in its own graphite shell (distinct from the warm-paper workspace shell) diff --git a/docs/guide/issues.md b/docs/guide/issues.md index f0a2eab2..06bcff66 100644 --- a/docs/guide/issues.md +++ b/docs/guide/issues.md @@ -5,13 +5,46 @@ initiatives, time entries, attachments — orbits the issue. This page covers the fields, the assignment model (humans and agents in parallel), the queue concept, bulk operations, and how relations link issues together. -## Fields +## Daily issue detail + +The issue header keeps the full key/title, status, priority, human assignees, +due date and private reminder together. Pins stay centered in the app header. +An assigned agent remains a separate control from human assignment. + +- **Attachments** are visible below the description with names/thumbnails, + Add file, preview/download controls and a four-file summary. Expand View all + for longer lists. GitHub PR links continue through native GitHub relations. +- **Related work** stays visible in the side rail: parent/subtasks, blocked-by, + blocks, duplicates and related issues, including linked titles and statuses. + Expand a group or the dependency map without replacing the list. +- **Automation & delivery** is always discoverable. Add agent or delivery opens + Ask an agent, Link implementation PR, and Start delivery session. Existing + agent outcomes, delivery ownership/stage, PR relations and warnings remain + visible; only supporting evidence expands. Mobile header links jump directly + to related work and automation. +- **Activity** is in the main column. Discussion shows comments and meaningful + outcomes, Updates shows system/status activity, and All activity combines the + loaded windows without duplicating comment events. Routine changes group into + expandable entries. Open decisions stay above the description regardless of + the selected filter. The comment composer remains available in every view. + +Ask an agent defaults to Discuss and prepares an editable comment; choosing +an agent does not assign it, dispatch a run, claim Delivery or post a comment. +The selected mode replaces prior mode instructions for that agent in the draft, +while other draft content is preserved. Work starts only after explicit submit. + +Legacy `?tab=activity`, `?tab=attachments` and `?tab=relations` links now jump to +visible sections; keys 1/2/3 focus those same sections when not editing. Comment +deep links load earlier comment pages as needed. Activity explicitly identifies +its bounded recent window rather than claiming to display unlimited history. + +## Fields reference | Field | Type | Notes | | ----------------- | -------- | ----------------------------------------------------------------------------- | | `id` | uuid | Internal identifier | | `key` | string | Public key, e.g. `WRK-42`. Workspace-prefixed and unique within the workspace | -| `kind` | enum | `ISSUE` or `TASK`. Tasks are lighter-weight; defaults are typically tighter | +| `kind` | enum | `EPIC`, `ISSUE`, or `TASK`. Tasks can stand alone or belong to a parent. | | `title` | string | Required, single line | | `description` | markdown | Optional, free-form | | `statusId` | fk | Status row in this workspace's status set | @@ -141,10 +174,13 @@ re-route. Two paths. **Quick create.** Press ⇧C from anywhere. The dialog covers title, -description, priority, project, and kind. ⌘ Enter submits. Creating +optional description, priority, project, assignee, labels, and due date. +Enter creates; ⌘ Enter creates and opens. Creating from a board column or status group also preserves that originating status; -creating from the command palette always opens issue mode, even while viewing -an existing issue. This is the primary creation path. +explicit **New issue** actions always open issue mode, even while viewing an +existing issue. On an issue detail page, ⇧C opens contextual comment +capture. A personal workspace's **New task** creates `TASK` work items rather +than `ISSUE` items. The QuickCreate input also recognises [slash commands](/guide/slash-commands.html) at the start of the line: @@ -228,6 +264,10 @@ limits. ## Snooze +For a private notification at a chosen time, use **Reminder → Remind me** on +the issue instead. See [Personal reminders](/guide/reminders.html) for scheduling, +snoozing a reminder, optional browser delivery, and completion behavior. + `Issue.snoozedUntil DateTime?` lets you mark an issue "intentionally on hold until later." While `snoozedUntil > now()`, the issue is filtered out of: diff --git a/docs/guide/navigation-and-agent-setup.md b/docs/guide/navigation-and-agent-setup.md new file mode 100644 index 00000000..c1ae5ef5 --- /dev/null +++ b/docs/guide/navigation-and-agent-setup.md @@ -0,0 +1,144 @@ +# Navigation and agent setup + +Forge separates everyday work, operations, account preferences, and administration. + +| Destination | Purpose | +| --- | --- | +| Home (`/`) | Your accessible open assignments, workspace shortcuts, Inbox and Activity. | +| Workspaces (`/workspaces`) | Workspaces you belong to; creation and authorized management links. | +| Mission Control (`/mission-control`) | Cross-workspace agent operations, delivery, runtime health and intervention links. | +| Agents (`/agents`) | Agent definitions, ownership, workspace access and connection setup. | +| My account (`/settings`) | Personal identity, sign-in, preferences and owned resources. | +| Administration (`/admin`) | Instance-wide people, workspaces, authentication, governance and infrastructure. | + +Workspace routes remain under `/w/`. Their members, integrations and +workflow settings apply to that workspace. `/settings/workspaces` redirects +to the directory for existing bookmarks. Mission Control's former operational +dashboard remains available at `/mission-control`. + +## Connect an agent + +Begin in **Agents** and choose the execution intent: + +- **Connect an external client**: your desktop app, CLI or MCP client executes + the work. Choose the workspace and either your personal identity or an agent. + Personal credentials act as the current user. Agent credentials identify the + agent and record the issuing operator. No managed runtime is required. +- **Let Forge run an agent**: choose the workspace and agent, then configure + its managed execution host. Workspace owner/admin authority is required for + managed setup; instance administration alone is not workspace membership. + +An existing agent with workspace access can continue directly to its scoped +MCP credential setup. Otherwise its detail page explains how an authorized +workspace administrator adds access. New agent definitions retain the existing +instance policy under Administration → Agent governance. The compatibility +default remains approval required; changing policy does not approve existing +requests, share identities, or grant workspace membership. + +## Ownership, availability and authorization + +These are separate facts: + +- **Owner** manages the agent definition. Personal identities belong to one + active human; service-owned identities are managed by instance administrators + independently of the person who created them. Creation provenance grants no + management authority and remains separate when ownership changes. +- **Catalog availability** determines whether other workspace administrators + may select the agent. Instance availability does not make the instance its + owner and grants no workspace membership or project visibility. +- **Workspace access** is the agent's explicit workspace association and policy. +- **Permissions** remain limited by workspace roles, project authorization, + service-principal grants and credential scopes/narrowing as applicable. +- **Connection** identifies a client or transport. A configured runtime is not + proof that it executed an MCP operation. A valid credential is not live presence. + +Profile owners do not gain access to other workspaces' runs or client metadata +through ownership. Explicit instance administrators retain administrative +visibility. Disabled, archived and pending-approval agents are unavailable +through either workspace or global setup. Dispatch secrets are not returned +by profile/catalog read surfaces; only their configured presence is exposed. + +## Runtime lifecycle safety + +Runtime ownership can extend beyond the workspace where it was registered. +The existing schema still retains that registration-home relationship. +Permanent deletion of a workspace with any registered runtime is blocked, +including archived, disabled, unbound or legacy ownerless runtimes. Archiving +the workspace remains available. Runtime archival does not waive this guard. + +The guard and deletion execute under a PostgreSQL workspace-row lock so a +concurrent runtime registration cannot slip between the check and deletion. +Object storage cleanup starts after these checks while the workspace still +reserves its slug, before the database row is deleted. This prevents cleanup +from deleting a new workspace's bucket after slug reuse. Storage and database +deletion remain non-atomic: a later database failure cannot restore removed +objects, and failed best-effort storage cleanup can leave orphaned objects. + +## Ownership management + +Administrators can create service-owned agents directly or transfer an existing +identity between personal and service ownership. Transfers use a reviewed owner +and version snapshot, validate active personal targets, and preserve sharing, +workspace access, client credentials, history and creator attribution. An +incompatible private runtime must be explicitly cleared before ownership changes. +Service profile keys are unique instance-wide; personal keys remain unique per +owner. A service identity is not a human login account. + +Personal creation policy supports: + +- **Disabled**: users cannot create or request personal identities; admins may. +- **Approval required**: requests remain pending until an admin approves them. +- **Self-service**: users can create their own approved, private identities. + +Self-service never grants service ownership, sharing, elevated runtime access, +workspace adoption or additional credential scopes. Existing identities and +pending requests are unchanged when the policy changes. + +## Runtime transfer and removal + +Administration → Runtimes shows archived registrations on demand and provides +an impact preview for **Archive & disable**, **Transfer ownership / home**, and +**Remove registration**. All three require exact-name confirmation and a fresh +snapshot. Child secret/repository/configuration edits invalidate that snapshot. + +Transfer/removal require the registration to be archived and disabled first. +This prevents future Forge use; it does not stop the external host or complete +its existing work. Active/unresolved runs, delivery, connections, sessions, +diagnostics and agent/profile attachments are blockers. + +Ownership-only transfers may preserve terminal history in the original home. +Moving the registration home additionally requires no historical tenant-scoped +references. Credentials, repositories, GitHub App links, nonempty adapter config, +or endpoint credentials/query parameters must be reviewed and cleared in the +original home before a transfer. Nothing is silently moved across tenants. +Use a new registration when existing history prevents a home move. + +Removal is for unused registrations only. The preview identifies any stored +secret/repository configuration that will be deleted; it never deletes or stops +the external runtime host. Historical registrations stay archived to preserve +evidence. New attachment of an **Unassigned** runtime requires explicit admin +adoption; existing legacy bindings are preserved until deliberately changed. + +MCP management respects the same ownership rules. Service API keys do not +inherit their issuer's instance-admin role. Ownerless service registrations +remain unassigned until an administrator adopts them. + +## Migration and release caution + +The ownership migration preserves existing human owners and does not invent +historical creators. Ownerless runtimes become explicitly UNASSIGNED. Personal +ownership foreign keys prevent hard-deleting an owner until resources are +transferred or removed; service resources survive creator deletion. + +The new ownership CHECK constraints require updated runtime writers to supply +their owner type. The production rollout must quiesce old web/worker +writers before applying this migration, then start the matching new application +and worker together. An old binary must not continue writing to the new schema. +Local validation does not authorize this production migration or deployment. + +## Scope boundary + +This ownership model does not replace the separate multi-user People & Access, +Teams, directory or SCIM program. No existing grants are migrated or widened. +Production migration and release require the operator-approved process in +`RELEASE.md`. diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 8f2943d5..40cba173 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -63,23 +63,30 @@ accidentally triggering navigation. ## 4. Create your first issue -From the Issues page, press ⇧C to open the quick-create dialog. +From the Issues page, press ⇧C to open quick create. You can also use +**New issue** in the sidebar or top bar. Those explicit actions create an issue +even while you are viewing another issue; ⇧C on an issue detail page +opens a contextual comment instead. Fill in: - **Title** — required. Keep it short and verb-led ("Wire up onboarding email"). -- **Description** — optional. Markdown supported. -- **Status** — defaults to the workspace's default status. -- **Priority** — `NONE`, `LOW`, `MEDIUM`, `HIGH`, or `URGENT`. -- **Project** — optional. Skip for now. +- **Description** — optional and visible below the title. +- **Status**, **Priority**, **Project**, **Assignee**, and **Due date** — direct + field controls. Agent assignment and labels are under **More options**. -Press ⌘ Enter to create. The issue lands in the list with a fresh -key (e.g. `WRK-1`). +The item-type selector also offers **Task**, **Note**, and planning items. +Escape saves the content and selected fields as a workspace-scoped draft. +Tab moves through fields; slash commands remain optional keyboard shortcuts. -For richer creation (sub-issues, attachments, relations), use the full-page -form at `/w//issues/new`. The quick-create dialog covers ~95% of -day-to-day creation. +Press Enter to create, or ⌘ Enter to create and open. The +issue gets a fresh key (e.g. `WRK-1`). + +Add sub-issues, attachments, relations, and sprint membership on the issue +detail page after capture. In a personal workspace, **New task** and the Today +composer create lighter-weight tasks; a due date makes a task appear in Today +or Upcoming, but does not itself send a reminder. ## 5. Drop it into a project diff --git a/docs/guide/reminders.md b/docs/guide/reminders.md new file mode 100644 index 00000000..9c8d1c37 --- /dev/null +++ b/docs/guide/reminders.md @@ -0,0 +1,32 @@ +# Personal reminders + +Use **Remind me** in the issue header beside its due date. Due reminders also +appear on cross-workspace Home, personal Today and the notification drawers. + +Open an issue or task and expand **Remind me**. Choose a date and +time or a preset, then select **Save reminder**. The time is interpreted in the +timezone shown by the form. Each person can keep one active reminder per item; +saving again replaces their own schedule. + +Due reminders appear in **Notifications → Your reminders** and on the personal +Today dashboard. Each card includes the issue key and title, an issue link, +**Snooze 1 hour**, and **Dismiss reminder**. Opening a reminder marks it read; +marking notifications read preserves the card until it is dismissed or snoozed. +The issue's Reminder control also supports changing or canceling the schedule. + +Reminders are private to their recipient and are checked against current +workspace and project access. Completing, canceling, archiving, or deleting the +item cancels its reminder; reopening does not revive a canceled reminder. +Setting a reminder does not modify due dates, add an issue comment, create a +decision request, or wake an agent. + +In-app reminders are durable: they become visible by their scheduled time even +when background delivery is unavailable. Open surfaces refresh at most every +30 seconds. Optional browser push uses your existing Forge browser subscriptions +and the maintenance worker's one-minute sweep. Browser push is best effort; +delivery also depends on the browser, device, subscription, and instance VAPID +configuration. The in-app card remains available if push cannot be delivered. + +A **due date** describes when work is due. **Snooze issue** hides work from +selected queues temporarily. **Scheduled tasks** create recurring issues. +None of those automatically schedules a personal reminder. diff --git a/docs/guide/slash-commands.md b/docs/guide/slash-commands.md index 6865cf1d..9fced86d 100644 --- a/docs/guide/slash-commands.md +++ b/docs/guide/slash-commands.md @@ -68,22 +68,22 @@ without posting a comment: ## Autocomplete -Both the QuickCreate input (issue / sub-issue modes) and the comment -composer surface a small dropdown when the cursor sits on a -top-of-body line that starts with `/`. The dropdown lists the seven -command keywords + a one-line example for each. +QuickCreate offers optional slash shortcuts at the end of the title (after a +space). Its visible field controls work without typing commands. The comment +composer offers its dropdown in a top-of-body line that starts with `/`. Keys: | Key | Effect | |---|---| | ↓ / ↑ | Move the active selection | -| Enter / Tab | Insert the active command's stub (e.g. `/assign `) | +| Enter | Insert the active command's stub (e.g. `/assign `) | +| Tab | Move to the next field in QuickCreate; insert the suggestion in the comment composer | | Esc | Dismiss without inserting | | Click | Inserts as if Enter were pressed | -So typing `/a` then Tab expands to `/assign ` with the -caret right after the trailing space — ready for the argument. The +So typing `/a` then Enter expands to `/assign ` with the +caret right after the trailing space — ready for the argument. In comments, the dropdown only opens when the line is in the contiguous top-of-body slash block; once you've typed prose below, it stops auto-opening on later slashes. diff --git a/prisma/backfill-multiws.ts b/prisma/backfill-multiws.ts index a2559724..a9b4ef96 100644 --- a/prisma/backfill-multiws.ts +++ b/prisma/backfill-multiws.ts @@ -12,7 +12,8 @@ * each existing Agent binding to it (Agent.profileId). The owner is * the workspace's OWNER (fallback: ADMIN, then any member). The * profile's definitional columns are copied from the binding. - * 3. Backfills Runtime.ownerId from the workspace owner where null. + * 3. Reports unassigned runtimes for explicit ownership review. Service + * resources deliberately have no human owner and must remain unchanged. * * Until this runs, Agent.profileId is null and the app treats each * binding's own columns as its definition (transitional). Running it is @@ -44,7 +45,6 @@ async function main() { let promoted = 0; let profilesCreated = 0; let bindingsLinked = 0; - let runtimesOwned = 0; // 1. Bootstrap instance admin. const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase(); @@ -113,19 +113,17 @@ async function main() { bindingsLinked++; } - // 3. Runtime ownership. - const runtimes = await db.runtime.findMany({ where: { ownerId: null }, select: { id: true, workspaceId: true } }); - for (const rt of runtimes) { - const ownerId = await workspaceOwnerUserId(rt.workspaceId); - if (ownerId) { - await db.runtime.update({ where: { id: rt.id }, data: { ownerId } }); - runtimesOwned++; - } + // 3. Registration location is not ownership. Leave historical unassigned + // runtimes for an administrator to transfer explicitly; never adopt SERVICE + // resources merely because their ownerId is intentionally null. + const unassignedRuntimes = await db.runtime.count({ where: { ownerType: "UNASSIGNED" } }); + if (unassignedRuntimes > 0) { + console.warn(`${unassignedRuntimes} unassigned runtimes need explicit ownership review in Administration.`); } console.log( `backfill-multiws done — promoted ${promoted} admin, created ${profilesCreated} profiles, ` + - `linked ${bindingsLinked} bindings, owned ${runtimesOwned} runtimes.`, + `linked ${bindingsLinked} bindings; ${unassignedRuntimes} runtimes await ownership review.`, ); } diff --git a/prisma/migrations/20261014160000_issue_reminders/migration.sql b/prisma/migrations/20261014160000_issue_reminders/migration.sql new file mode 100644 index 00000000..657813e0 --- /dev/null +++ b/prisma/migrations/20261014160000_issue_reminders/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "IssueReminder" ( + "id" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "issueId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "remindAt" TIMESTAMP(3) NOT NULL, + "timezone" TEXT NOT NULL, + "browserPush" BOOLEAN NOT NULL DEFAULT false, + "readAt" TIMESTAMP(3), + "dismissedAt" TIMESTAMP(3), + "notifiedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "IssueReminder_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "IssueReminder_userId_issueId_key" ON "IssueReminder"("userId", "issueId"); +CREATE INDEX "IssueReminder_workspaceId_userId_remindAt_idx" ON "IssueReminder"("workspaceId", "userId", "remindAt"); +CREATE INDEX "IssueReminder_browserPush_notifiedAt_remindAt_idx" ON "IssueReminder"("browserPush", "notifiedAt", "remindAt"); +CREATE INDEX "IssueReminder_issueId_idx" ON "IssueReminder"("issueId"); +ALTER TABLE "IssueReminder" ADD CONSTRAINT "IssueReminder_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "IssueReminder" ADD CONSTRAINT "IssueReminder_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "Issue"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "IssueReminder" ADD CONSTRAINT "IssueReminder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20261015111500_resource_ownership_policy/migration.sql b/prisma/migrations/20261015111500_resource_ownership_policy/migration.sql new file mode 100644 index 00000000..87a13ff6 --- /dev/null +++ b/prisma/migrations/20261015111500_resource_ownership_policy/migration.sql @@ -0,0 +1,44 @@ +CREATE TYPE "ResourceOwnerType" AS ENUM ('PERSONAL', 'SERVICE', 'UNASSIGNED'); +CREATE TYPE "PersonalAgentCreation" AS ENUM ('DISABLED', 'APPROVAL_REQUIRED', 'SELF_SERVICE'); + +ALTER TABLE "AgentProfile" + ADD COLUMN "ownerType" "ResourceOwnerType" NOT NULL DEFAULT 'PERSONAL', + ADD COLUMN "createdById" TEXT, + ALTER COLUMN "ownerId" DROP NOT NULL; +ALTER TABLE "Runtime" + ADD COLUMN "ownerType" "ResourceOwnerType" NOT NULL DEFAULT 'UNASSIGNED', + ADD COLUMN "createdById" TEXT; + +-- Keep existing human ownership. Do not invent a service owner or historical creator. +UPDATE "Runtime" SET "ownerType" = 'PERSONAL' WHERE "ownerId" IS NOT NULL; + +ALTER TABLE "AgentProfile" DROP CONSTRAINT "AgentProfile_ownerId_fkey"; +ALTER TABLE "AgentProfile" ADD CONSTRAINT "AgentProfile_ownerId_fkey" + FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Runtime" DROP CONSTRAINT "Runtime_ownerId_fkey"; +ALTER TABLE "Runtime" ADD CONSTRAINT "Runtime_ownerId_fkey" + FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "AgentProfile" ADD CONSTRAINT "AgentProfile_createdById_fkey" + FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Runtime" ADD CONSTRAINT "Runtime_createdById_fkey" + FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "AgentProfile" ADD CONSTRAINT "AgentProfile_owner_shape_check" CHECK ( + ("ownerType" = 'PERSONAL' AND "ownerId" IS NOT NULL) OR + ("ownerType" = 'SERVICE' AND "ownerId" IS NULL) +); +ALTER TABLE "Runtime" ADD CONSTRAINT "Runtime_owner_shape_check" CHECK ( + ("ownerType" = 'PERSONAL' AND "ownerId" IS NOT NULL) OR + ("ownerType" IN ('SERVICE', 'UNASSIGNED') AND "ownerId" IS NULL) +); +-- SQL partial uniqueness covers service identities with no human owner. +CREATE UNIQUE INDEX "AgentProfile_service_profileKey_key" ON "AgentProfile"("profileKey") WHERE "ownerType" = 'SERVICE'; + +CREATE TABLE "InstanceAgentPolicy" ( + "id" TEXT NOT NULL DEFAULT 'default', + "personalAgentCreation" "PersonalAgentCreation" NOT NULL DEFAULT 'APPROVAL_REQUIRED', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "InstanceAgentPolicy_pkey" PRIMARY KEY ("id"), + CONSTRAINT "InstanceAgentPolicy_singleton_check" CHECK ("id" = 'default') +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 78c0cb81..6fc2a4a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,6 +32,19 @@ enum Role { GUEST } +/// Ownership is independent of catalog sharing and workspace authorization. +enum ResourceOwnerType { + PERSONAL + SERVICE + UNASSIGNED +} + +enum PersonalAgentCreation { + DISABLED + APPROVAL_REQUIRED + SELF_SERVICE +} + /// Default audience for a project. RESTRICTED projects require an explicit /// ProjectAccess row for non-administrators. enum ProjectVisibility { @@ -855,6 +868,7 @@ enum AgentRunStatus { // ---------------------------------------------------------------------------- model User { + issueReminders IssueReminder[] id String @id @default(cuid()) email String @unique /// Canonical case-insensitive identity key. All account creation and lookup @@ -959,7 +973,8 @@ model User { memberships Membership[] invitationsSent WorkspaceInvitation[] @relation("InvitationSender") invitationsAccepted WorkspaceInvitation[] @relation("InvitationAcceptor") - agentProfiles AgentProfile[] + agentProfiles AgentProfile[] @relation("AgentProfileOwner") + createdAgentProfiles AgentProfile[] @relation("AgentProfileCreator") requestedAgentProfiles AgentProfile[] @relation("AgentProfileRequester") connections Connection[] externalResourceLinks ExternalResourceLink[] @relation("ExternalResourceLinkCreator") @@ -989,7 +1004,8 @@ model User { timeEntries TimeEntry[] chatThreads ChatThread[] chatThreadReads ChatThreadRead[] - runtimes Runtime[] + runtimes Runtime[] @relation("RuntimeOwner") + createdRuntimes Runtime[] @relation("RuntimeCreator") controlRequestedRuns AgentRun[] @relation("AgentRunControlRequester") clearedAgentRuns AgentRun[] @relation("AgentRunClearer") pins Pin[] @@ -1086,6 +1102,14 @@ model InstanceAuthPolicy { breakGlassUser User? @relation("InstanceBreakGlassUser", fields: [breakGlassUserId], references: [id], onDelete: Restrict) } +/// Singleton policy; default preserves the existing approval-required behavior. +model InstanceAgentPolicy { + id String @id @default("default") + personalAgentCreation PersonalAgentCreation @default(APPROVAL_REQUIRED) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + /// Optional local sign-in method attached to the same canonical User used by /// every OAuth/OIDC identity. model LocalCredential { @@ -1198,6 +1222,7 @@ model SsoProvider { // ---------------------------------------------------------------------------- model Workspace { + issueReminders IssueReminder[] id String @id @default(cuid()) slug String @unique name String @@ -1696,6 +1721,7 @@ model Label { } model Issue { + reminders IssueReminder[] id String @id @default(cuid()) workspaceId String projectId String? @@ -1840,6 +1866,31 @@ model ProjectTemplate { @@index([workspaceId]) } +/// Private, one-shot reminder. A due date or issue snooze does not create one. +model IssueReminder { + id String @id @default(cuid()) + workspaceId String + issueId String + userId String + remindAt DateTime + timezone String + browserPush Boolean @default(false) + readAt DateTime? + dismissedAt DateTime? + notifiedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, issueId]) + @@index([workspaceId, userId, remindAt]) + @@index([browserPush, notifiedAt, remindAt]) + @@index([issueId]) +} + /// Reusable issue starting-points scoped to a workspace (or project). model IssueTemplate { id String @id @default(cuid()) @@ -2569,12 +2620,12 @@ model Runtime { /// Stored only for ops display; the underlying ApiKey row is the /// source of truth. ownerKeyPrefix String? - /// User who registered this runtime. The global owner — global runtime - /// views (Mission Control, /settings/runtimes) key off this rather than - /// `workspaceId`, so a host appears once across all the owner's - /// workspaces. `workspaceId` is retained (where the daemon first - /// registered) for back-compat; treat `ownerId` as the global identity. + /// Human owner, independent of the registration-home workspace. SERVICE + /// runtimes are instance-managed; UNASSIGNED preserves legacy ambiguity. + ownerType ResourceOwnerType @default(UNASSIGNED) ownerId String? + /// Creation provenance only; never a permission grant. Legacy rows stay null. + createdById String? /// Instance admin can mark a runtime instance-shared so it appears in /// every workspace's runtime picker (not just the owner's). Mirrors /// {@link AgentProfile.instanceShared}. @@ -2604,7 +2655,8 @@ model Runtime { updatedAt DateTime @updatedAt workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull) + owner User? @relation("RuntimeOwner", fields: [ownerId], references: [id], onDelete: Restrict) + createdBy User? @relation("RuntimeCreator", fields: [createdById], references: [id], onDelete: SetNull) agents Agent[] @relation("AgentRuntime") agentProfiles AgentProfile[] @relation("AgentProfileRuntime") secrets RuntimeSecret[] @@ -3205,8 +3257,12 @@ model ExternalWebhookEvent { /// `profileId` is null (un-backfilled rows). model AgentProfile { id String @id @default(cuid()) - /// User who owns / defined this profile. - ownerId String + /// PERSONAL profiles have a human owner; SERVICE profiles are managed by + /// instance administrators and survive the issuing human's departure. + ownerType ResourceOwnerType @default(PERSONAL) + ownerId String? + /// Creator provenance only. Unknown historical creators remain null. + createdById String? /// Stable handle, unique per owner. Maps to a Hermes profile dir name. profileKey String name String @@ -3244,7 +3300,8 @@ model AgentProfile { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + owner User? @relation("AgentProfileOwner", fields: [ownerId], references: [id], onDelete: Restrict) + createdBy User? @relation("AgentProfileCreator", fields: [createdById], references: [id], onDelete: SetNull) requestedBy User? @relation("AgentProfileRequester", fields: [requestedById], references: [id], onDelete: SetNull) runtime Runtime? @relation("AgentProfileRuntime", fields: [runtimeId], references: [id], onDelete: SetNull) bindings Agent[] @relation("AgentProfileBinding") diff --git a/prisma/seed.ts b/prisma/seed.ts index 539b7094..8b85fca0 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -338,7 +338,9 @@ async function main() { kind: RuntimeKind.REMOTE_HTTP, adapterKey: "mock-runs", endpoint: "mock://e2e", + ownerType: "PERSONAL", ownerId: owner.id, + createdById: owner.id, heartbeatAt: e2eNow, connectedAt: e2eNow, }, @@ -378,7 +380,9 @@ async function main() { adapterKey: "codex-app-server", endpoint: "ws://127.0.0.1:4505", config: {}, + ownerType: "PERSONAL", ownerId: owner.id, + createdById: owner.id, }, }); } diff --git a/scripts/lib/dev-workflow.ts b/scripts/lib/dev-workflow.ts index e5cf4743..331e32ef 100644 --- a/scripts/lib/dev-workflow.ts +++ b/scripts/lib/dev-workflow.ts @@ -21,7 +21,7 @@ export type DevOptions = { }; export function parseWindowsPnpmEntry(shim: string): string | undefined { - return shim.match(/%~dp0\\([^"\r\n]*pnpm\.(?:mjs|cjs|js))/i)?.[1]; + return shim.match(/(?:%~dp0|%dp0%)\\([^"\r\n]*pnpm\.(?:mjs|cjs|js))/i)?.[1]; } export function parseDevOptions(argv: string[]): DevOptions { diff --git a/src/app/(app)/admin/users/page.tsx b/src/app/(app)/admin/users/page.tsx index 68d00ce6..d6669c7d 100644 --- a/src/app/(app)/admin/users/page.tsx +++ b/src/app/(app)/admin/users/page.tsx @@ -9,9 +9,9 @@ export default function AdminUsersPage() { return ( diff --git a/src/app/(app)/agents/[id]/page.tsx b/src/app/(app)/agents/[id]/page.tsx index 81f70943..6347345a 100644 --- a/src/app/(app)/agents/[id]/page.tsx +++ b/src/app/(app)/agents/[id]/page.tsx @@ -3,13 +3,15 @@ import { GlobalShell } from "@/components/global-shell/global-shell"; import { getGlobalShellData } from "@/components/global-shell/data"; import { AgentDetailContent } from "@/app/(app)/settings/agents/[id]/agent-detail-content"; -/** Mission Control profile detail and binding control surface. */ +/** Agent identity, workspace access, and connection detail. */ export default async function AgentFleetDetailPage({ params, + searchParams, }: { params: Promise<{ id: string }>; + searchParams: Promise<{ workspace?: string; setup?: string }>; }) { - const [data, { id }] = await Promise.all([getGlobalShellData(), params]); + const [data, { id }, query] = await Promise.all([getGlobalShellData(), params, searchParams]); if (!data) redirect("/signin"); return ( @@ -18,8 +20,7 @@ export default async function AgentFleetDetailPage({ workspaces={data.workspaces} activePath="/agents" crumbs={[ - { label: "Forge", href: "/" }, - { label: "Mission Control", href: "/" }, + { label: "Home", href: "/" }, { label: "Agents", href: "/agents" }, { label: "Profile" }, ]} @@ -30,6 +31,10 @@ export default async function AgentFleetDetailPage({ id={id} isInstanceAdmin={data.user.instanceRole === "INSTANCE_ADMIN"} workspaces={data.workspaces} + initialWorkspaceId={query.workspace} + setupMode={ + query.setup === "external" || query.setup === "managed" ? query.setup : undefined + } /> ); diff --git a/src/app/(app)/agents/page.tsx b/src/app/(app)/agents/page.tsx index 5736c603..0889b8ed 100644 --- a/src/app/(app)/agents/page.tsx +++ b/src/app/(app)/agents/page.tsx @@ -3,7 +3,7 @@ import { GlobalShell } from "@/components/global-shell/global-shell"; import { getGlobalShellData } from "@/components/global-shell/data"; import { AgentsContent } from "@/app/(app)/settings/agents/agents-content"; -/** Mission Control fleet: define, bind, and operate global agent identities. */ +/** Agent identity, access, and connection setup. */ export default async function AgentFleetPage({ searchParams, }: { @@ -17,14 +17,10 @@ export default async function AgentFleetPage({ user={data.user} workspaces={data.workspaces} activePath="/agents" - crumbs={[ - { label: "Forge", href: "/" }, - { label: "Mission Control", href: "/" }, - { label: "Agents" }, - ]} - title="Agent fleet" - subtitle="Define identities, connect execution, bind workspaces, and monitor live use." - eyebrow="Mission Control" + crumbs={[{ label: "Home", href: "/" }, { label: "Agents" }]} + title="Agents" + subtitle="Manage identities, workspace access, and how agents connect or execute." + eyebrow="Agents" scope="control" contentClass="flex flex-col overflow-hidden" > diff --git a/src/app/(app)/mission-control/page.tsx b/src/app/(app)/mission-control/page.tsx new file mode 100644 index 00000000..1d01844d --- /dev/null +++ b/src/app/(app)/mission-control/page.tsx @@ -0,0 +1,22 @@ +import { redirect } from "next/navigation"; +import { GlobalShell } from "@/components/global-shell/global-shell"; +import { getGlobalShellData } from "@/components/global-shell/data"; +import { MissionControlHome } from "@/components/global-shell/mission-control-home"; + +export default async function MissionControlPage() { + const data = await getGlobalShellData(); + if (!data) redirect("/signin"); + + return ( + + + + ); +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index e8fefef5..e8680f36 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -1,15 +1,13 @@ import { redirect } from "next/navigation"; import { GlobalShell } from "@/components/global-shell/global-shell"; import { getGlobalShellData } from "@/components/global-shell/data"; -import { MissionControlHome } from "@/components/global-shell/mission-control-home"; +import { PersonalHome } from "@/components/global-shell/personal-home"; /** - * Mission Control — the cross-workspace home. Renders inside the global - * ("concourse") shell. Shell chrome (user + workspaces for the rail) is - * resolved server-side; the aggregations stream client-side from the - * `global.*` tRPC routers. Part of the multi-workspace restructure. + * Personal home across accessible workspaces. Operational health has its + * own /mission-control route so runtime setup does not gate everyday work. */ -export default async function MissionControlPage() { +export default async function HomePage() { const data = await getGlobalShellData(); if (!data) redirect("/signin"); @@ -18,13 +16,12 @@ export default async function MissionControlPage() { user={data.user} workspaces={data.workspaces} activePath="/" - crumbs={[{ label: "Forge", href: "/" }, { label: "Mission Control" }]} - title="Mission Control" - subtitle="Across all workspaces" - eyebrow="Home" + crumbs={[{ label: "Home" }]} + title="My work" + subtitle="Your assignments and workspaces, together." big > - + ); } diff --git a/src/app/(app)/settings/access/page.tsx b/src/app/(app)/settings/access/page.tsx index c7fb272f..977944e2 100644 --- a/src/app/(app)/settings/access/page.tsx +++ b/src/app/(app)/settings/access/page.tsx @@ -91,8 +91,8 @@ const PROVIDERS: Array<{ { id: "codex", label: "Codex", - eyebrow: "CLI/IDE", - description: "Codex CLI or IDE extension using streamable HTTP MCP config.", + eyebrow: "desktop/CLI/IDE", + description: "Codex Desktop, CLI, or IDE extension connected to Forge over MCP.", Icon: Terminal, }, { @@ -152,8 +152,8 @@ export default function AccessPage() { const [step, setStep] = useState(0); const [provider, setProvider] = useState("hermes"); const [name, setName] = useState(""); - const [scopes, setScopes] = useState(FULL_ACCESS); - const [preset, setPreset] = useState("full"); + const [scopes, setScopes] = useState(DEFAULT_SCOPES); + const [preset, setPreset] = useState("default"); const [expiresInDays, setExpiresInDays] = useState(""); const [linkedAgentId, setLinkedAgentId] = useState(""); @@ -222,8 +222,8 @@ export default function AccessPage() { function openCreate() { setProvider("hermes"); setName(""); - setScopes(FULL_ACCESS); - setPreset("full"); + setScopes(DEFAULT_SCOPES); + setPreset("default"); setExpiresInDays(""); setLinkedAgentId(""); setStep(0); @@ -377,8 +377,8 @@ export default function AccessPage() { const nextProvider = isMcpProvider(providerParam) ? providerParam : "hermes"; setProvider(nextProvider); setName(`${PROVIDER_LABEL.get(nextProvider) ?? "Agent"} Forge MCP`); - setScopes(FULL_ACCESS); - setPreset("full"); + setScopes(DEFAULT_SCOPES); + setPreset("default"); setExpiresInDays(""); setLinkedAgentId(requestedAgentId ?? ""); setStep(0); @@ -1363,8 +1363,8 @@ function Intro({ baseUrl }: { baseUrl: string }) {
This page is the credential source of truth: create, inspect, rotate, revoke, and delete - every MCP client here. Primary execution runtimes stay with the identity in Mission Control - → Agents. + every MCP client here. These clients execute externally and do not require a managed + runtime. Agent identity and optional managed execution are configured in Agents.
); diff --git a/src/app/(app)/settings/agents/[id]/agent-detail-content.tsx b/src/app/(app)/settings/agents/[id]/agent-detail-content.tsx index 17f3b095..d6ade9b0 100644 --- a/src/app/(app)/settings/agents/[id]/agent-detail-content.tsx +++ b/src/app/(app)/settings/agents/[id]/agent-detail-content.tsx @@ -27,6 +27,8 @@ import { trpc } from "@/lib/trpc"; import { relativeTime } from "@/lib/utils"; import { workspaceChipColor } from "@/components/global-shell/global-shell"; import { AgentAvatar } from "@/components/agents/agent-avatar"; +import { agentOwnerLabel } from "@/lib/agent-ownership-ui"; +import { TransferAgentOwnershipDialog } from "../transfer-agent-ownership-dialog"; /** * Client body for the agent profile detail page. Reads @@ -107,20 +109,28 @@ function ReadinessCard({ label: string; value: string; detail: string; - ready: boolean; + ready: boolean | null; }) { const StateIcon = ready ? CheckCircle2 : TriangleAlert; return (
{label} - + {ready !== null && ( + + )}
{value}

{detail}

@@ -140,10 +150,14 @@ export function AgentDetailContent({ id, isInstanceAdmin, workspaces, + initialWorkspaceId, + setupMode, }: { id: string; isInstanceAdmin: boolean; workspaces: ProfileWorkspace[]; + initialWorkspaceId?: string; + setupMode?: "external" | "managed"; }) { const router = useRouter(); const utils = trpc.useUtils(); @@ -157,15 +171,30 @@ export function AgentDetailContent({ runtimeId: string; capabilities: string; } | null>(null); - const [bindWorkspaceId, setBindWorkspaceId] = useState(""); + const [bindWorkspaceId, setBindWorkspaceId] = useState( + workspaces.some( + (workspace) => + workspace.id === initialWorkspaceId && + (workspace.role === "OWNER" || workspace.role === "ADMIN"), + ) + ? initialWorkspaceId! + : "", + ); const [unbindTarget, setUnbindTarget] = useState<{ agentId: string; workspaceId: string; workspaceName: string; } | null>(null); const [dangerOpen, setDangerOpen] = useState(false); + const [transferOpen, setTransferOpen] = useState(false); const { data: a, isLoading, error } = trpc.agents.profiles.get.useQuery({ id }); - const { data: runtimes } = trpc.global.runtimes.useQuery(); + const { data: runtimes } = trpc.global.runtimeOptions.useQuery( + { + ownerType: a?.ownerType === "SERVICE" ? "SERVICE" : "PERSONAL", + ownerId: a?.ownerType === "PERSONAL" ? (a.ownerId ?? undefined) : undefined, + }, + { enabled: !!a?.canEdit }, + ); const updateProfile = trpc.agents.profiles.update.useMutation({ onSuccess: async () => { toast.success("Agent profile saved"); @@ -178,7 +207,7 @@ export function AgentDetailContent({ }); const bindProfile = trpc.agents.bindings.bindFromMissionControl.useMutation({ onSuccess: async () => { - toast.success("Agent bound to the workspace."); + toast.success("Agent added to the workspace."); setBindWorkspaceId(""); await Promise.all([ utils.agents.profiles.get.invalidate({ id }), @@ -189,7 +218,7 @@ export function AgentDetailContent({ }); const unbindProfile = trpc.agents.bindings.unbindFromMissionControl.useMutation({ onSuccess: async () => { - toast.success("Agent unbound. Runs, chats, and history are preserved."); + toast.success("Workspace access removed. Runs, chats, and history are preserved."); setUnbindTarget(null); await Promise.all([ utils.agents.profiles.get.invalidate({ id }), @@ -210,7 +239,7 @@ export function AgentDetailContent({ toast.success( result.action === "deleted" ? `Deleted ${result.name}.` - : `Archived ${result.name}; ${result.boundAgents} workspace binding${result.boundAgents === 1 ? "" : "s"} still reference it.`, + : `Archived ${result.name}; ${result.boundAgents} workspace${result.boundAgents === 1 ? "" : "s"} retain its history.`, ); router.push("/agents"); }, @@ -268,14 +297,17 @@ export function AgentDetailContent({ (client) => !client.revokedAt && (!client.expiresAt || new Date(client.expiresAt).getTime() > Date.now()), ); - const executionReady = !!a.runtime; const profile = a; const boundWorkspaceIds = new Set(a.bindings.map((binding) => binding.workspace.id)); const bindableWorkspaces = workspaces.filter( (workspace) => + a.availability.state === "AVAILABLE" && + (isInstanceAdmin || a.ownedByMe || a.instanceShared) && (workspace.role === "OWNER" || workspace.role === "ADMIN") && !boundWorkspaceIds.has(workspace.id), ); + const setupWorkspace = workspaces.find((workspace) => workspace.id === initialWorkspaceId); + const setupBinding = a.bindings.find((binding) => binding.workspace.id === initialWorkspaceId); function beginDefinitionEdit() { setDefinitionDraft({ @@ -338,42 +370,126 @@ export function AgentDetailContent({ All agents - Global profile + Agent identity
+ {setupMode && setupWorkspace && ( +
+

+ {setupMode === "external" ? "Connect an MCP client" : "Configure managed execution"}{" "} + · {setupWorkspace.name} +

+

+ {!setupBinding + ? "Add workspace access below to continue. This requires a workspace owner or admin." + : setupMode === "external" + ? "Issue a scoped client credential for this workspace. The client executes externally; the runtime configuration below does not describe its execution." + : "Review the identity’s managed runtime, then configure routing and approvals for this workspace. A configured host alone does not prove that a run executed."} +

+
+ {!setupBinding ? ( + + Review workspace access → + + ) : setupMode === "external" && setupBinding.canManage ? ( + + Add MCP client → + + ) : ( + + Workspace agent policy → + + )} + {setupMode === "managed" && ( + <> + + Review runtime assignment → + + + Manage workspace runtimes → + + + )} +
+
+ )} +
+ + {agentOwnerLabel(a)} + {isInstanceAdmin && ( + + )} + + + {a.instanceShared + ? "Instance catalog" + : a.ownerType === "SERVICE" + ? "Instance-admin setup only" + : "Owner-only catalog"}{" "} + · {a.availability.state.toLowerCase().replaceAll("_", " ")} + + + {a.createdBy?.name ?? "Historical creator not recorded"} +

+ Creation provenance is preserved independently of ownership. +

+
+

+ Catalog availability allows an administrator to add an identity. Actual access is + granted per workspace below; ownership does not grant access to another workspace’s + data. +

+
0} + ready={null} /> 0} + value={`${activeClients.length} valid credentials`} + detail="Valid keys do not prove that a client or delivery session is currently active." + ready={null} />
{/* Definition */} +
- + options={Object.values(AgentProvider).map((value) => ({ + value, + label: value.toLowerCase(), + }))} + className="h-9 w-full" + /> - + options={[ + { value: "DEFAULT", label: "Default" }, + ...Object.values(RunEngine).map((value) => ({ + value, + label: value.toLowerCase(), + })), + ]} + className="h-9 w-full" + /> - + allowNone + noneLabel="Not configured" + placeholder="Not configured" + options={[ + ...(a.runtime && + !(runtimes ?? []).some((runtime) => runtime.id === a.runtime!.id) + ? [{ value: a.runtime.id, label: a.runtime.name, secondary: "Assigned" }] + : []), + ...(runtimes ?? []).map((runtime) => ({ + value: runtime.id, + label: runtime.name, + secondary: runtime.kind.toLowerCase().replaceAll("_", " "), + })), + ]} + className="h-9 w-full" + /> )} - + {a.runtime ? ( @@ -526,14 +643,20 @@ export function AgentDetailContent({ ) : ( - unassigned + + Not configured · not required for MCP + )} - + {a.instanceShared ? ( - Shared with the whole instance + Available for workspace admins to add ) : ( - Private to you + + {a.ownerType === "SERVICE" + ? "Instance-admin setup only" + : "Owner or instance-admin setup only"} + )}
@@ -559,14 +682,14 @@ export function AgentDetailContent({
{a.bindings.length === 0 ? ( ) : ( a.bindings.map((binding) => { @@ -585,7 +708,7 @@ export function AgentDetailContent({ {binding.workspace.name} - {bindingActive.length} active + {bindingActive.length} valid credentials
@@ -628,14 +751,14 @@ export function AgentDetailContent({
Template markdown
- {a.bindings.length} active binding{a.bindings.length === 1 ? "" : "s"} + {a.bindings.length} enabled workspace{a.bindings.length === 1 ? "" : "s"}
{a.canEdit && ( @@ -706,14 +829,15 @@ export function AgentDetailContent({
{/* Workspace bindings */} +
0 ? (
setBindWorkspaceId(value ?? "")} options={bindableWorkspaces.map((workspace) => ({ @@ -734,7 +858,7 @@ export function AgentDetailContent({ bindProfile.mutate({ workspaceId: bindWorkspaceId, profileId: a.id }) } > - Bind + Add access
) : undefined @@ -752,7 +876,7 @@ export function AgentDetailContent({ {a.bindings.length === 0 ? ( ) : ( @@ -829,7 +953,7 @@ export function AgentDetailContent({ }) } > - Unbind + Remove access )} @@ -840,7 +964,10 @@ export function AgentDetailContent({
{/* Recent runs */} -
+
{a.recentRuns.length === 0 ? ( @@ -887,8 +1014,8 @@ export function AgentDetailContent({

{isInstanceAdmin - ? "Unused profiles are deleted permanently. Profiles with workspace bindings are archived so runs, chats, and history remain intact." - : "Archiving hides the profile from Mission Control and binding catalogs while preserving workspace history."} + ? "Unused profiles are deleted permanently. Profiles used in workspaces are archived so runs, chats, and history remain intact." + : "Archiving hides the identity from Agents and setup catalogs while preserving workspace history."}

+ {transferOpen && isInstanceAdmin && ( + setTransferOpen(false)} /> + )} !open && setUnbindTarget(null)} variant="destructive" title={`Unbind from ${unbindTarget?.workspaceName}?`} - description="The agent stops being available in this workspace. Runs, chats, and history are preserved, and re-binding restores the same binding." + description="The agent stops being available in this workspace. Runs, chats, and history are preserved. Adding access again restores the same workspace identity." primaryLabel="Unbind" loading={unbindProfile.isPending} onConfirm={async () => { @@ -934,8 +1064,8 @@ export function AgentDetailContent({ title={`${isInstanceAdmin ? "Remove" : "Archive"} ${a.name}?`} description={ isInstanceAdmin - ? "If the profile has never been bound, it is permanently deleted. Otherwise it is archived and all workspace history stays intact." - : "The profile is hidden from the fleet and binding catalogs. Existing workspace history is preserved." + ? "If the identity has never been used in a workspace, it is permanently deleted. Otherwise it is archived and all workspace history stays intact." + : "The identity is hidden from Agents and setup catalogs. Existing workspace history is preserved." } primaryLabel={isInstanceAdmin ? "Remove profile" : "Archive profile"} loading={isInstanceAdmin ? removeProfile.isPending : archiveProfile.isPending} diff --git a/src/app/(app)/settings/agents/agent-setup-dialog.tsx b/src/app/(app)/settings/agents/agent-setup-dialog.tsx new file mode 100644 index 00000000..dc575b3a --- /dev/null +++ b/src/app/(app)/settings/agents/agent-setup-dialog.tsx @@ -0,0 +1,201 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { CenterModal } from "@/components/ui/modal"; +import { Combobox } from "@/components/ui/combobox"; +import { agentSetupDestination, type AgentSetupMode } from "@/lib/agent-setup"; +import { + personalAgentCreationCopy, + type PersonalAgentCreationPolicy, +} from "@/lib/agent-ownership-ui"; + +type Workspace = { id: string; slug: string; name: string; key: string; role?: string }; +type Profile = { + id: string; + name: string; + ownedByMe: boolean; + instanceShared: boolean; + ownerType?: string; + availability: { state: string }; + bindings: Array<{ id: string; workspace: { id: string } }>; +}; + +export function AgentSetupDialog({ + mode, + workspaces, + profiles, + initialWorkspaceId, + isInstanceAdmin, + personalCreationPolicy, + onClose, + onCreate, +}: { + mode: AgentSetupMode; + workspaces: Workspace[]; + profiles: Profile[]; + initialWorkspaceId?: string; + isInstanceAdmin: boolean; + personalCreationPolicy?: PersonalAgentCreationPolicy; + onClose: () => void; + onCreate: (workspaceId: string) => void; +}) { + const [workspaceId, setWorkspaceId] = useState( + workspaces.some((workspace) => workspace.id === initialWorkspaceId) + ? initialWorkspaceId! + : workspaces.length === 1 + ? workspaces[0].id + : "", + ); + const [profileId, setProfileId] = useState(""); + const workspace = workspaces.find((item) => item.id === workspaceId); + const profile = profiles.find((item) => item.id === profileId); + const canManage = workspace?.role === "OWNER" || workspace?.role === "ADMIN"; + const destination = agentSetupDestination({ mode, workspace, profile }); + const binding = profile?.bindings.find((item) => item.workspace.id === workspaceId); + const creation = personalAgentCreationCopy(personalCreationPolicy); + + return ( + { + if (!open) onClose(); + }} + size="md" + title={mode === "external" ? "Connect an external client" : "Let Forge run an agent"} + description={ + mode === "external" + ? "Use Codex Desktop, a CLI, or another MCP client. Your client executes the work; no Forge runtime is required." + : "Forge dispatches work to a managed execution runtime. Choose the workspace and agent before configuring its host." + } + footer={ +
+ + {destination ? ( + + Continue + + ) : ( + + )} +
+ } + > +
+
+

Workspace access

+ { + setWorkspaceId(value ?? ""); + setProfileId(""); + }} + options={workspaces.map((item) => ({ + value: item.id, + label: item.name, + secondary: item.key, + }))} + placeholder="Choose workspace…" + className="w-full" + /> +

+ Credentials and permissions apply only to this workspace. You can add other workspaces + separately. +

+
+
+

Acting identity

+ setProfileId(value ?? "")} + options={[ + ...(mode === "external" + ? [{ value: "", label: "My account", secondary: "Personal MCP token" }] + : []), + ...profiles + .filter( + (item) => + item.availability.state === "AVAILABLE" && + (isInstanceAdmin || item.ownedByMe || item.instanceShared), + ) + .map((item) => ({ + value: item.id, + label: item.name, + secondary: + item.ownerType === "SERVICE" + ? "Service-owned" + : item.ownedByMe + ? "Owned by you" + : "Available agent", + })), + ]} + placeholder={ + mode === "external" ? "My account · Personal MCP token" : "Choose an agent…" + } + className="w-full" + /> +

+ {profile + ? "The agent is the acting identity. The issued key also records the human operator; sharing an identity does not grant workspace access." + : mode === "external" + ? "Actions are attributed to your user account. Choose an agent above for a separate agent identity." + : "Choose an existing agent or create/request a new identity below."} +

+
+ {workspace?.role === "GUEST" && ( +

+ Guests cannot create credentials. Ask a workspace administrator for access. +

+ )} + {workspace && mode === "managed" && !canManage && ( +

+ Managed setup requires an owner or admin of this workspace. +

+ )} + {workspace && profile && mode === "external" && (!binding || !canManage) && ( +

+ Continue to this agent’s workspace access.{" "} + {canManage + ? "Add it to the workspace, then choose Add MCP client." + : "A workspace administrator must add the agent or issue its client credential."} +

+ )} + {workspace && mode === "managed" && canManage && ( + + Register or inspect a runtime in {workspace.name} → + + )} +
+ +

+ {isInstanceAdmin + ? "Choose personal or service ownership when creating an identity. Workspace access and catalog sharing remain separate controls." + : creation.description} +

+
+
+
+ ); +} diff --git a/src/app/(app)/settings/agents/agents-content.tsx b/src/app/(app)/settings/agents/agents-content.tsx index 2bb2f817..2b55cc47 100644 --- a/src/app/(app)/settings/agents/agents-content.tsx +++ b/src/app/(app)/settings/agents/agents-content.tsx @@ -1,8 +1,9 @@ "use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useState, type ReactNode } from "react"; -import { Bot, ChevronRight, KeyRound, Link2, Plus, Server, Shield } from "lucide-react"; +import { Bot, ChevronRight, KeyRound, Link2, Server } from "lucide-react"; import { AgentProvider, RunEngine } from "@prisma/client"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -14,6 +15,9 @@ import { trpc } from "@/lib/trpc"; import { workspaceChipColor } from "@/components/global-shell/global-shell"; import { AgentAvatar } from "@/components/agents/agent-avatar"; import { RequestProfileDialog } from "@/app/(app)/w/[slug]/settings/agents/request-profile-dialog"; +import { AgentSetupDialog } from "./agent-setup-dialog"; +import { matchesAgentView, type AgentListView, type AgentSetupMode } from "@/lib/agent-setup"; +import { agentOwnerLabel, personalAgentCreationCopy } from "@/lib/agent-ownership-ui"; /** * Client body for the global agent profiles list. Each card links to its @@ -68,8 +72,10 @@ export function AgentsContent({ workspaces: Workspace[]; initialWorkspaceId?: string; }) { + const router = useRouter(); const { data: profiles, isLoading } = trpc.agents.profiles.list.useQuery(); - const { data: runtimes } = trpc.global.runtimes.useQuery(); + const policy = trpc.agents.profiles.policy.useQuery(); + const creation = personalAgentCreationCopy(policy.data?.personalAgentCreation); const utils = trpc.useUtils(); const [createOpen, setCreateOpen] = useState(false); const [name, setName] = useState(""); @@ -80,8 +86,17 @@ export function AgentsContent({ const [description, setDescription] = useState(""); const [avatar, setAvatar] = useState(""); const [capsText, setCapsText] = useState(""); - const [instanceShared, setInstanceShared] = useState(true); + const [instanceShared, setInstanceShared] = useState(false); + const [ownerType, setOwnerType] = useState<"PERSONAL" | "SERVICE">("PERSONAL"); + const { data: runtimes } = trpc.global.runtimeOptions.useQuery( + { ownerType }, + { enabled: isInstanceAdmin && createOpen }, + ); const [requestOpen, setRequestOpen] = useState(false); + const [setupMode, setSetupMode] = useState(null); + const [creationMode, setCreationMode] = useState("managed"); + const [setupWorkspaceId, setSetupWorkspaceId] = useState(initialWorkspaceId ?? ""); + const [view, setView] = useState("available"); const [workspaceId, setWorkspaceId] = useState( initialWorkspaceId && workspaces.some((workspace) => workspace.id === initialWorkspaceId) ? initialWorkspaceId @@ -90,10 +105,11 @@ export function AgentsContent({ const visibleProfiles = (profiles ?? []).filter( (profile) => - workspaceId === "all" || - (workspaceId === "unbound" - ? profile.bindings.length === 0 - : profile.bindings.some((binding) => binding.workspace.id === workspaceId)), + matchesAgentView(profile, view, isInstanceAdmin) && + (workspaceId === "all" || + (workspaceId === "unbound" + ? profile.bindings.length === 0 + : profile.bindings.some((binding) => binding.workspace.id === workspaceId))), ); function resetCreate() { @@ -105,15 +121,19 @@ export function AgentsContent({ setDescription(""); setAvatar(""); setCapsText(""); - setInstanceShared(true); + setInstanceShared(false); + setOwnerType("PERSONAL"); } const createProfile = trpc.agents.profiles.create.useMutation({ - onSuccess: () => { + onSuccess: (created) => { toast.success("Agent profile created."); void utils.agents.profiles.list.invalidate(); resetCreate(); setCreateOpen(false); + router.push( + `/agents/${created.id}?workspace=${encodeURIComponent(setupWorkspaceId)}&setup=${creationMode}`, + ); }, onError: (e) => toast.error(e.message), }); @@ -132,11 +152,12 @@ export function AgentsContent({ profileKey: profileKey.trim(), provider, runEngine: runEngine === "DEFAULT" ? null : runEngine, - runtimeId: runtimeId || null, + runtimeId: creationMode === "managed" ? runtimeId || null : null, description: description.trim() || undefined, avatar: avatar.trim() || undefined, baseCapabilities, instanceShared, + ownerType, }); } @@ -144,68 +165,44 @@ export function AgentsContent({ <>
-
- {[ - ["Define", "Identity & runtime"], - ["Bind", "Workspace adoption"], - ["Operate", "Runs & connections"], - ["Govern", isInstanceAdmin ? "Instance policy" : "Admin controlled"], - ].map(([step, detail], index) => ( -
-
- 0{index + 1} - {step} -
-

{detail}

-
- ))} -
- - {/* Create affordance / admin-gated hint */} -
- -
- New agent profiles are instance-admin-gated. - - {isInstanceAdmin - ? "You can create and share profiles across the instance." - : "Members can request a profile; an instance admin approves it."} - -
- {isInstanceAdmin ? ( - - ) : ( -
+
+ +

Let Forge run an agent

+

+ Choose an identity and runtime for work dispatched and monitored by Forge. +

+ - )} +
+ {!isInstanceAdmin && ( +

+ {policy.isError + ? "Creation policy could not be loaded. Try refreshing before creating a new identity." + : creation.description} +

+ )}
- Fleet scope - - Global profiles - + Agent identities

- Filter by workspace binding without leaving Mission Control. + Ownership, catalog availability, and workspace access are separate.

@@ -216,7 +213,7 @@ export function AgentsContent({ onChange={(value) => setWorkspaceId(value ?? "all")} options={[ { value: "all", label: "All workspaces" }, - { value: "unbound", label: "Not bound" }, + { value: "unbound", label: "No visible workspace access" }, ...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, @@ -228,6 +225,25 @@ export function AgentsContent({ />
+
+ {( + [ + { value: "mine", label: "My agents" }, + { value: "available", label: "Available to me" }, + ...(isInstanceAdmin ? [{ value: "all", label: "All agents · Admin" }] : []), + ] as const + ).map((item) => ( + + ))} +
{isLoading ? (
@@ -238,14 +254,14 @@ export function AgentsContent({ variant="section" icon={} title="No agent profiles yet" - description="Agent profiles are global identities you bind into workspaces." + description="Start with a personal MCP connection or create/request an agent identity using setup above." /> ) : visibleProfiles.length === 0 ? ( } title="No profiles in this fleet view" - description="Choose another workspace filter or bind a profile from its Mission Control detail page." + description="Choose another filter or add workspace access from an agent’s detail page." /> ) : (
@@ -257,7 +273,7 @@ export function AgentsContent({ >
-
+
{a.name} @@ -265,17 +281,22 @@ export function AgentsContent({ {a.instanceShared && ( - instance + Instance catalog )} - {!a.ownedByMe && ( - - shared + {!a.instanceShared && ( + + {a.ownerType === "SERVICE" ? "Admin-only setup" : "Owner-only catalog"} )} - - global profile - + {a.availability.state !== "AVAILABLE" && ( + + {a.availability.state.toLowerCase().replaceAll("_", " ")} + + )} +
+
+ Owner: {agentOwnerLabel(a)}
{a.description && (
@@ -298,19 +319,19 @@ export function AgentsContent({ {a.runtime.name}
- {a.runtime.kind.toLowerCase().replace("_", " ")} + Managed execution configured
) : ( - unassigned + No managed runtime )}
- {a.bindings.length} binding{a.bindings.length === 1 ? "" : "s"} + {a.bindings.length} workspace{a.bindings.length === 1 ? "" : "s"} @@ -342,6 +363,26 @@ export function AgentsContent({ )}
+ {setupMode && ( + setSetupMode(null)} + onCreate={(selectedWorkspaceId) => { + setSetupWorkspaceId(selectedWorkspaceId); + setCreationMode(setupMode); + setSetupMode(null); + if (isInstanceAdmin) setCreateOpen(true); + else if (creation.enabled) setRequestOpen(true); + }} + /> + )} { @@ -350,12 +391,36 @@ export function AgentsContent({ }} size="md" title="Create agent profile" - description="Create the identity, then choose its one primary execution runtime. Workspace bindings and MCP clients can be added afterward." + description={ + creationMode === "external" + ? "Create the acting identity, then add workspace access and an MCP client key. External execution does not require a managed runtime." + : "Create the acting identity and optionally select a managed runtime. Continue to workspace access after creation." + } primaryLabel="Create profile" onPrimary={submitCreate} loading={createProfile.isPending} >
+ + { + setOwnerType(value === "SERVICE" ? "SERVICE" : "PERSONAL"); + setRuntimeId(""); + }} + options={[ + { value: "PERSONAL", label: "Personal · owned by my account" }, + { value: "SERVICE", label: "Service-owned · instance administrators" }, + ]} + className="w-full" + /> +

+ {ownerType === "SERVICE" + ? "A durable service identity managed by instance administrators. Your account is recorded as creator, not owner. Catalog availability is configured separately below." + : "You own this identity. An instance administrator can transfer ownership later; sharing does not change its owner."} +

+
- - - - - - - + onChange={(value) => { + if (value) setProvider(value as AgentProvider); + }} + options={Object.values(AgentProvider).map((value) => ({ + value, + label: value.toLowerCase(), + }))} + className="h-9 w-full" + /> + {creationMode === "managed" && ( + + setRunEngine((value ?? "DEFAULT") as "DEFAULT" | RunEngine)} + options={[ + { value: "DEFAULT", label: "Default" }, + ...Object.values(RunEngine).map((value) => ({ + value, + label: value.toLowerCase(), + })), + ]} + className="h-9 w-full" + /> + + )} + {creationMode === "managed" && ( + + setRuntimeId(value ?? "")} + ariaLabel="Primary execution runtime" + allowNone + noneLabel="Not assigned yet" + placeholder="Not assigned yet" + options={(runtimes ?? []).map((runtime) => ({ + value: runtime.id, + label: runtime.name, + secondary: runtime.kind.toLowerCase().replaceAll("_", " "), + }))} + className="h-9 w-full" + /> + + )}
- Execution host + {" "} + {creationMode === "external" ? "External execution" : "Managed execution"}
- Forge starts work through this runtime. MCP clients are separate and can be added per - workspace. + {creationMode === "external" + ? "Your MCP client executes the work. Add workspace access and a client credential after creating this identity." + : "Forge dispatches work through the selected runtime. MCP clients are separate connections and can be added per workspace."}
setAvatar(e.target.value)} placeholder="VI" /> @@ -457,13 +531,28 @@ export function AgentsContent({ Share in every workspace catalog - Turn this off for a private profile that only the owner can bind. + Allows workspace admins to add this identity. It does not grant access to any + workspace or data.{" "} + {ownerType === "SERVICE" + ? "Without catalog sharing, only instance administrators can set it up." + : "Owner-only by default."}
- + { + setView("mine"); + if (created.approvedAt) + router.push( + `/agents/${created.id}?workspace=${encodeURIComponent(setupWorkspaceId)}&setup=${creationMode}`, + ); + }} + /> ); } diff --git a/src/app/(app)/settings/agents/page.tsx b/src/app/(app)/settings/agents/page.tsx index 56323183..f3eed49b 100644 --- a/src/app/(app)/settings/agents/page.tsx +++ b/src/app/(app)/settings/agents/page.tsx @@ -1,9 +1,8 @@ import { redirect } from "next/navigation"; /** - * Global agent **profiles** — the user-owned definitions (the same * Compatibility route retained for bookmarks from the former account-level - * Agent Studio. Mission Control now owns agent management at `/agents`. + * Agent Studio. Agent management now lives at `/agents`. */ export default async function GlobalAgentsPage() { redirect("/agents"); diff --git a/src/app/(app)/settings/agents/transfer-agent-ownership-dialog.tsx b/src/app/(app)/settings/agents/transfer-agent-ownership-dialog.tsx new file mode 100644 index 00000000..51aa2e1a --- /dev/null +++ b/src/app/(app)/settings/agents/transfer-agent-ownership-dialog.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/ui/combobox"; +import { CenterModal } from "@/components/ui/modal"; +import { agentOwnerLabel } from "@/lib/agent-ownership-ui"; + +type Profile = { + id: string; + name: string; + ownerType: string; + ownerId: string | null; + owner: { name: string | null } | null; + updatedAt: Date; + instanceShared: boolean; + bindingCount: number; + runtime: { name: string } | null; +}; + +export function TransferAgentOwnershipDialog({ + profile, + onClose, +}: { + profile: Profile; + onClose: () => void; +}) { + // Hold the reviewed ownership snapshot. A background refetch must not silently + // accept a different owner or updated profile during confirmation. + const [snapshot] = useState(profile); + const currentOwnerType = + snapshot.ownerType === "PERSONAL" || snapshot.ownerType === "SERVICE" + ? snapshot.ownerType + : null; + const [ownerType, setOwnerType] = useState<"PERSONAL" | "SERVICE">( + currentOwnerType ?? "PERSONAL", + ); + const [ownerId, setOwnerId] = useState(snapshot.ownerId ?? ""); + const [clearRuntime, setClearRuntime] = useState(false); + const [acknowledged, setAcknowledged] = useState(false); + const [stale, setStale] = useState(false); + const targets = trpc.agents.profiles.ownershipCandidates.useQuery(); + const utils = trpc.useUtils(); + const transfer = trpc.agents.profiles.transferOwnership.useMutation({ + onSuccess: async () => { + await Promise.all([ + utils.agents.profiles.get.invalidate({ id: snapshot.id }), + utils.agents.profiles.list.invalidate(), + ]); + toast.success( + "Agent ownership transferred. Workspace access and credentials were not transferred.", + ); + onClose(); + }, + onError: (error) => { + toast.error(error.message); + if (error.data?.code === "CONFLICT") { + setStale(true); + void utils.agents.profiles.get.invalidate({ id: snapshot.id }); + } + }, + }); + const selectedUser = targets.data?.find((user) => user.id === ownerId); + const changed = + snapshot.ownerType !== ownerType || (ownerType === "PERSONAL" && snapshot.ownerId !== ownerId); + const canSubmit = + !!currentOwnerType && + changed && + acknowledged && + !stale && + !transfer.isPending && + (ownerType === "SERVICE" || !!selectedUser); + + function submit() { + if (!canSubmit || !currentOwnerType) return; + transfer.mutate({ + id: snapshot.id, + ownerType, + ownerId: ownerType === "PERSONAL" ? ownerId : undefined, + expectedOwnerType: currentOwnerType, + expectedOwnerId: snapshot.ownerId, + expectedUpdatedAt: snapshot.updatedAt, + clearRuntime, + }); + } + + return ( + { + if (!open && !transfer.isPending) onClose(); + }} + title={`Transfer ownership · ${snapshot.name}`} + description="Change who manages this agent identity. Review the impact before confirming." + size="md" + footer={ +
+ + +
+ } + > +
+

+ Current owner: {agentOwnerLabel(snapshot)} +

+ { + setOwnerType(value === "SERVICE" ? "SERVICE" : "PERSONAL"); + setAcknowledged(false); + }} + options={[ + { value: "PERSONAL", label: "Personal · active user" }, + { value: "SERVICE", label: "Service-owned · instance administrators" }, + ]} + className="w-full" + /> + {ownerType === "PERSONAL" && ( +
+ { + setOwnerId(value ?? ""); + setAcknowledged(false); + }} + searchable + loading={targets.isLoading} + options={(targets.data ?? []).map((user) => ({ + value: user.id, + label: user.name ?? user.email ?? "Unnamed user", + secondary: user.email ?? undefined, + }))} + placeholder="Choose an active user…" + className="w-full" + /> +

+ Only active accounts can receive personal ownership. +

+ {targets.isError && ( +

+ Owner choices could not load. Close and retry before transferring. +

+ )} +
+ )} +
+

+ {ownerType === "SERVICE" + ? "Instance administrators will manage this service identity. It will no longer belong to a human account." + : `${selectedUser?.name ?? "The selected user"} will manage this identity. Previous personal owner rights are removed unless they are also an instance administrator.`} +

+

+ {snapshot.bindingCount} workspace access grants remain unchanged. Catalog availability + stays {snapshot.instanceShared ? "instance-shared" : "restricted"}. Credential owners, + scopes, history, and creator attribution remain unchanged. +

+

Ownership does not grant access to workspace data or transfer secret credentials.

+
+ {snapshot.runtime && ( + + )} + + {stale && ( +

+ The agent changed after this dialog opened. Close it and review the refreshed ownership + before trying again. +

+ )} +
+
+ ); +} diff --git a/src/app/(app)/settings/layout.tsx b/src/app/(app)/settings/layout.tsx index e65685f6..5f1c2da8 100644 --- a/src/app/(app)/settings/layout.tsx +++ b/src/app/(app)/settings/layout.tsx @@ -9,7 +9,7 @@ import { SettingsRail } from "@/components/settings/settings-rail"; * settings (account, agents, runtimes, connections, workspaces). Workspace * settings live under `/w/[slug]/settings/*` and use the workspace shell. * These sit *above* any single workspace, so the back-link returns to - * Mission Control (`/`), not into a workspace. + * Home (`/`), not into a workspace. */ export default async function AccountSettingsLayout({ children, @@ -24,7 +24,7 @@ export default async function AccountSettingsLayout({
{children}
diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 8f698be8..805bf38f 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -8,13 +8,13 @@ import { ACCOUNT_SETTINGS_GROUP } from "@/components/settings/settings-nav"; const RESOURCES = [ { href: "/settings/runtimes", - label: "Runtime inventory", - hint: "Hosts you own, their health, and their authoritative home workspace.", + label: "My runtimes", + hint: "Execution hosts you own and the workspace where each was registered. Agent access is managed separately in Agents.", icon: Server, }, { href: "/settings/connections", - label: "Integration accounts", + label: "Connected accounts", hint: "Operational OAuth credentials that workspace mappings can use. Separate from sign-in.", icon: PlugZap, }, @@ -53,10 +53,10 @@ function OverviewLink({ export default function PersonalSettingsOverview() { return ( <> - +
-
+
{ACCOUNT_SETTINGS_GROUP.items.map((item) => (
{RESOURCES.map((item) => ( diff --git a/src/app/(app)/settings/runtimes/page.tsx b/src/app/(app)/settings/runtimes/page.tsx index dd7cf5e3..0de934c1 100644 --- a/src/app/(app)/settings/runtimes/page.tsx +++ b/src/app/(app)/settings/runtimes/page.tsx @@ -9,6 +9,7 @@ import { RuntimeSelfTestBadge, RuntimeSelfTestLine } from "@/components/settings import { trpc } from "@/lib/trpc"; import { relativeTime } from "@/lib/utils"; import { workspaceChipColor } from "@/components/global-shell/global-shell"; +import { runtimeOwnershipLabel } from "@/lib/runtime-ownership"; /** * Global runtimes — the compute hosts the user has registered (local @@ -64,8 +65,8 @@ export default function RuntimesPage() { return ( <>
@@ -90,17 +91,17 @@ export default function RuntimesPage() {
- Claude Code, Codex CLI, and one-off MCP sessions live under Agent access. + Connect desktop apps, CLI tools, and other MCP clients from Agents. Runtime hosts are for managed compute that Forge dials or monitors.
- Open Agent access + Connect an agent
@@ -167,6 +168,14 @@ export default function RuntimesPage() {
+
+
Owner
+
{runtimeOwnershipLabel(r)}
+
+
+
Registration home
+
{r.homeWorkspace?.name ?? "Restricted workspace"}
+
Last signal
{r.health.lastSignal}
@@ -236,9 +245,10 @@ export default function RuntimesPage() { )}
- Runtimes are global to your account, not a workspace concern.{" "} + Ownership, registration home, and workspace access are separate. A runtime can serve + multiple workspaces; owning it does not grant access to their work.{" "} - Manage the agent fleet + Manage agents and workspace access {" "} to put them to work.
diff --git a/src/app/(app)/settings/workspaces/page.tsx b/src/app/(app)/settings/workspaces/page.tsx index e05c3c58..511b1f2b 100644 --- a/src/app/(app)/settings/workspaces/page.tsx +++ b/src/app/(app)/settings/workspaces/page.tsx @@ -1,441 +1,6 @@ -"use client"; -import { useMemo, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { toast } from "sonner"; -import { Plus, ArrowRight, AlertTriangle, UserRound, UsersRound } from "lucide-react"; -import { Topbar } from "@/components/topbar"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Dialog } from "@/components/ui/dialog"; -import { Card, EmptyState, MOTION, Section, SkeletonList } from "@/components/ui"; -import { trpc } from "@/lib/trpc"; -import { cn } from "@/lib/utils"; -import { workspaceColor } from "@/lib/workspace-color"; +import { redirect } from "next/navigation"; -/** - * Account-level workspaces page. Lives at `/settings/workspaces` (not - * under any `/w/[slug]`) so it remains reachable when the user has no - * active workspace. Workspace-scoped settings (rename, sprint length, danger - * zone) live under `/w/[slug]/settings/workspace`; this page is about - * moving *between* workspaces plus creating new ones. - * - * `workspace.create` seeds profile-aware labels and defaults. Personal - * workspaces intentionally skip the initial sprint. - */ -export default function WorkspacesPage() { - const router = useRouter(); - const { data: workspaces, refetch, isLoading } = trpc.workspace.list.useQuery(); - // Cross-workspace mini-stats (members / openIssues / agents / activeRuns), - // keyed by id so each row can show its tiles. Loads alongside the list; - // tiles render once it resolves (rows still work without it). - const { data: stats } = trpc.global.workspaces.useQuery(); - const statsById = useMemo( - () => new Map((stats ?? []).map((s) => [s.id, s])), - [stats], - ); - const setLast = trpc.workspace.setLastWorkspace.useMutation(); - const [createOpen, setCreateOpen] = useState(false); - - function open(w: { id: string; slug: string }) { - setLast.mutate({ workspaceId: w.id }); - router.push(`/w/${w.slug}/inbox`); - } - - return ( - <> - setCreateOpen(true)}> - - Create workspace - - } - /> -
-
-
- {isLoading ? ( - - ) : ( - - {(workspaces?.length ?? 0) === 0 && ( - } - title="No workspaces yet" - description="Create one to get started." - action={ - - } - /> - )} - {workspaces?.map((w) => { - const c = workspaceColor(w.key); - const role = w.memberships[0]?.role ?? "MEMBER"; - const st = statsById.get(w.id); - return ( -
  • - -
  • - ); - })} -
    - )} -
    - - {/* Danger zone — leaving / archiving / deleting a workspace is a - workspace-scoped, admin-gated action. This account-level page - can't self-leave (no account-scoped mutation), so we group the - destructive actions here and route each workspace to its own - manage page where the real mutations live. */} - {(workspaces?.length ?? 0) > 0 && ( -
    -
    - -

    Danger zone

    - - Leaving a workspace removes you for yourself only. Reversible only by an admin re-inviting you. - -
    -
      - {workspaces?.map((w) => ( -
    • -
      -
      - {w.name} - - {w.key} - -
      -
      - Leave, archive, or delete this workspace from its own settings. -
      -
      - - Manage - -
    • - ))} -
    -
    - )} -
    -
    - - setCreateOpen(false)} - onCreated={(slug) => { - refetch(); - router.push(`/w/${slug}/inbox`); - }} - existingKeys={new Set(workspaces?.map((w) => w.key) ?? [])} - existingSlugs={new Set(workspaces?.map((w) => w.slug) ?? [])} - /> - - ); -} - -function CreateDialog({ - open, - onClose, - onCreated, - existingKeys, - existingSlugs, -}: { - open: boolean; - onClose: () => void; - onCreated: (slug: string) => void; - existingKeys: Set; - existingSlugs: Set; -}) { - const [name, setName] = useState(""); - const [key, setKey] = useState(""); - const [slug, setSlug] = useState(""); - const [cycleLengthDays, setCycleLengthDays] = useState(7); - const [timeTrackingEnabled, setTimeTrackingEnabled] = useState(false); - const [experienceProfile, setExperienceProfile] = useState<"TEAM" | "PERSONAL">("TEAM"); - - const create = trpc.workspace.create.useMutation({ - onSuccess: (ws) => { - toast.success("Workspace created."); - reset(); - onClose(); - onCreated(ws.slug); - }, - onError: (e) => toast.error(e.message), - }); - - function reset() { - setName(""); - setKey(""); - setSlug(""); - setCycleLengthDays(7); - setTimeTrackingEnabled(false); - setExperienceProfile("TEAM"); - } - - function onNameChange(v: string) { - setName(v); - if (!slug) { - setSlug( - v - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 48), - ); - } - if (!key) { - const suggested = v - .split(/\s+/) - .filter(Boolean) - .slice(0, 3) - .map((w) => w[0]?.toUpperCase() ?? "") - .join(""); - const singleWord = v.replace(/[^a-zA-Z]/g, "").slice(0, 3).toUpperCase(); - if (suggested.length >= 2) setKey(suggested.slice(0, 5)); - else if (singleWord.length >= 2) setKey(singleWord); - } - } - - const issues = useMemo(() => { - const errs: string[] = []; - if (name.trim().length < 1) errs.push("Name is required."); - if (!/^[A-Z]{2,6}$/.test(key)) errs.push("Key must be 2–6 uppercase letters."); - else if (existingKeys.has(key)) errs.push(`Key ${key} is already in use.`); - if (!/^[a-z0-9-]{2,48}$/.test(slug)) - errs.push("Slug must be 2–48 lowercase alphanumeric chars or dashes."); - else if (existingSlugs.has(slug)) errs.push(`Slug ${slug} is already in use.`); - if (experienceProfile === "TEAM" && (cycleLengthDays < 1 || cycleLengthDays > 90)) - errs.push("Sprint length must be between 1 and 90 days."); - return errs; - }, [name, key, slug, cycleLengthDays, experienceProfile, existingKeys, existingSlugs]); - - return ( - -
    { - e.preventDefault(); - if (issues.length > 0) return; - create.mutate({ - name: name.trim(), - key, - slug, - cycleLengthDays, - timeTrackingEnabled, - experienceProfile, - }); - }} - className="space-y-3 p-5" - > -
    Create workspace
    -

    - The key shows up in issue identifiers like ACME-42 - {" "}and is permanent. Slug appears in URLs; it can be renamed later by an - owner via a data migration. -

    - - -
    - - -
    -
    - - - onNameChange(e.target.value)} - maxLength={80} - autoFocus - aria-label="Workspace name" - /> - -
    - - setKey(e.target.value.replace(/[^a-zA-Z]/g, "").toUpperCase())} - maxLength={6} - placeholder="ACME" - className="font-mono" - aria-label="Workspace key" - /> - - - - setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "")) - } - maxLength={48} - placeholder="acme" - className="font-mono" - aria-label="Workspace slug" - /> - -
    - {experienceProfile === "TEAM" &&
    - - setCycleLengthDays(Number(e.target.value) || 7)} - /> - - - - -
    } - -
    - The key cannot be changed later. Pick something short and stable. -
    - - {issues.length > 0 && ( -
      - {issues.map((m) => ( -
    • · {m}
    • - ))} -
    - )} - -
    - - -
    -
    -
    - ); -} - -/** Compact per-workspace mini-stat tile (members / open / agents / live). */ -function Stat({ label, value, ember }: { label: string; value: number; ember?: boolean }) { - return ( -
    -
    - {value} -
    -
    - {label} -
    -
    - ); -} - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
    -
    - {label} -
    - {children} -
    - ); +/** Keep bookmarks to the former account-settings directory working. */ +export default function LegacyWorkspacesPage() { + redirect("/workspaces"); } diff --git a/src/app/(app)/w/[slug]/issues/[id]/page.tsx b/src/app/(app)/w/[slug]/issues/[id]/page.tsx index d69facf9..31bcc310 100644 --- a/src/app/(app)/w/[slug]/issues/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/issues/[id]/page.tsx @@ -1,4 +1,5 @@ "use client"; +import { IssueReminderControl } from "@/components/reminders/issue-reminder-control"; import { use, useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { toast } from "sonner"; @@ -45,8 +46,10 @@ import { useWorkspace } from "@/hooks/use-workspace"; import { PinButton } from "@/components/pins/pin-button"; import { WatchButton } from "@/components/watch-button"; import { IssueDetailTopbar } from "@/components/issue-detail/issue-topbar"; -import { WorkCoordinationPanel } from "@/components/issue-detail/work-coordination-panel"; -import { IssueMain } from "@/components/issue-detail/issue-main"; +import { IssueAutomationPanel } from "@/components/issue-detail/issue-automation-panel"; +import { IssueMain, dispatchQuickReply } from "@/components/issue-detail/issue-main"; +import { IssueAttachmentsPanel } from "@/components/attachments/issue-attachments-panel"; +import { IssueRelationsPanel } from "@/components/relations/issue-relations-panel"; import { IssueRail } from "@/components/issue-detail/issue-rail"; import { AgentRunStrip } from "@/components/issue-detail/agent-run-strip"; import { TerminalRunFailureBanner } from "@/components/issue-detail/run-failure-banner"; @@ -56,7 +59,6 @@ import { IssueBlockerControl, } from "@/components/issue-detail/issue-blocker-control"; import { RuntimePreflightBanner } from "@/components/issue-detail/runtime-preflight-banner"; -import { GitHubLinksPanel } from "@/components/issue-detail/github-links-panel"; import { AiTriageCard } from "@/components/ai-triage-card"; import { CreateLabelModal } from "@/components/inline-create/create-label-modal"; import { Combobox } from "@/components/ui/combobox"; @@ -77,7 +79,7 @@ const PRIORITIES = ["NONE", "LOW", "MEDIUM", "HIGH", "URGENT"] as const; * Issue detail page. Two-column layout above `md`: * * [ main column (flex-1, lg:max-w-4xl xl:max-w-5xl) | right rail ] - * description + comments Properties + tabs + * description + files + subtasks + timeline Properties + context * * The outer scrollable area is capped at `max-w-[1600px] mx-auto` so * ultra-wide monitors don't leave gigantic gutters. The main column @@ -85,10 +87,9 @@ const PRIORITIES = ["NONE", "LOW", "MEDIUM", "HIGH", "URGENT"] as const; * comments get more breathing room without becoming uncomfortable to * scan. Padding scales `p-5 md:p-6 xl:p-8`. * - * The right rail hosts everything that isn't the description / comments: - * a "Properties" header (project / labels / due / agent queue) plus the - * existing tabs (Attachments / Relations / Activity). Tab state lives in - * `?tab=…` so deep-links work. A secondary header ("IssueDetailTopbar") + * The rail keeps properties, related work and automation summaries visible. + * Legacy `?tab=…` links jump to the corresponding visible section. The + * secondary header ("IssueDetailTopbar") * below the shell hosts the inline editors (title, status, * priority, assignees) — keeps the description above the fold without a * separate metadata column below it. @@ -211,6 +212,7 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri const [archiveOpen, setArchiveOpen] = useState(false); const [releaseOpen, setReleaseOpen] = useState(false); const [agentPickerOpen, setAgentPickerOpen] = useState(false); + const [agentPickerIntent, setAgentPickerIntent] = useState<"assign" | "request">("assign"); useEffect(() => { if (issue && !editingTitle) setTitleDraft(issue.title); @@ -218,7 +220,14 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri // Capital A opens the agent picker. Lowercase `a` is intentionally left // free for the user-assignee picker + as the `g a` nav leader second key. - useHotkey("shift+a", () => setAgentPickerOpen(true), []); + useHotkey( + "shift+a", + () => { + setAgentPickerIntent("assign"); + setAgentPickerOpen(true); + }, + [], + ); // Phase 1C — record this visit so it surfaces in the command palette's // Recents rail. Server-side debounced 5s; the keyed effect re-fires @@ -431,11 +440,9 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri )}
    @@ -554,15 +565,50 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri }))} onChange={(userIds) => assign.mutate({ id: issue.id, userIds })} /> - setAgentPickerOpen(true)} /> +
    + + update.mutate({ id: issue.id, dueDate: value ? new Date(value) : null }) + } + placeholder="Set due date" + ariaLabel="Due date" + className="text-xs" + /> +
    + {issue.status.category !== "DONE" && issue.status.category !== "CANCELED" && ( + + )} + {issue.assignedAgent && ( + { + setAgentPickerIntent("assign"); + setAgentPickerOpen(true); + }} + /> + )} + + Related work + + + Automation +
    } actions={siblingScope ? : null} />
    -
    + {/* Leave scroll clearance for the fixed mobile navigation and activity dock. */} +
    @@ -579,13 +625,6 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri - - } onDescriptionSave={(next) => update.mutate({ id: issue.id, description: next })} canResolveActions={(() => { if (!me?.id) return false; @@ -643,13 +683,15 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri aria-label="Issue detail rail" className="min-w-0 shrink-0 md:w-[22rem] md:self-start xl:w-[26rem]" > -
    +
    - - +
    +

    Properties

    setLabels.mutate({ issueId: issue.id, labelIds })} /> - - - update.mutate({ - id: issue.id, - dueDate: v ? new Date(v) : null, - }) - } - placeholder="No due date" - ariaLabel="Due date" - className="font-mono text-xs" - /> - - - update.mutate({ id: issue.id, slaMinutes })} - /> - +
    + + Service target{issue.slaMinutes ? " · configured" : " · optional"} + + + update.mutate({ id: issue.id, slaMinutes })} + /> + +
    +
    + } + > + + { + setAgentPickerIntent("assign"); + setAgentPickerOpen(true); + }} + onAskAgent={() => { + setAgentPickerIntent("request"); + setAgentPickerOpen(true); + }} + > + + {(issue.assignedAgent || issue.queued || issue.claimedAt) && ( - {issue.claimedAt && ( - - setReleaseOpen(true)} - /> - - )} -
    - } - /> + )} + {issue.claimedAt && ( + + setReleaseOpen(true)} + /> + + )} + +
    @@ -786,13 +847,21 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri { + if (agentPickerIntent === "request") { + if (selectedAgent) dispatchQuickReply(issue.id, "", selectedAgent.profileKey, mode); + setAgentPickerOpen(false); + return; + } // Reassignment-confirmation toast: when the assigned agent // *changes* (not on the initial assign), reassure the // operator that context is preserved. The standard @@ -1678,6 +1747,7 @@ function AgentPickerModal({ onOpenChange, currentAgentId, defaultMode, + intent = "assign", onSelect, }: { open: boolean; @@ -1685,6 +1755,7 @@ function AgentPickerModal({ currentAgentId: string | null; /** Workspace `assignmentEngagementMode` — the picker's starting mode. */ defaultMode: EngagementModeValue; + intent?: "assign" | "request"; onSelect: ( agentId: string | null, mode: EngagementModeValue, @@ -1720,7 +1791,7 @@ function AgentPickerModal({ }); const items: PickerRow[] = [ - { kind: "unassign", key: "__unassign" }, + ...(intent === "assign" ? [{ kind: "unassign" as const, key: "__unassign" }] : []), ...filteredAgents.map((a) => ({ kind: "agent" as const, key: a.id, @@ -1738,7 +1809,11 @@ function AgentPickerModal({ open={open} onOpenChange={onOpenChange} - placeholder="Assign agent… (name, @profileKey, capability)" + placeholder={ + intent === "request" + ? "Ask an agent… (name, @profileKey, capability)" + : "Assign agent… (name, @profileKey, capability)" + } items={items} getKey={(it) => it.key} onQueryChange={setQuery} @@ -1750,6 +1825,11 @@ function AgentPickerModal({ }} footer={
    + {intent === "request" && ( +

    + Choose a mode and agent to prepare a comment. Nothing runs until you send it. +

    + )} Engagement mode diff --git a/src/app/(app)/w/[slug]/issues/page.tsx b/src/app/(app)/w/[slug]/issues/page.tsx index 5fcf6c71..e5574669 100644 --- a/src/app/(app)/w/[slug]/issues/page.tsx +++ b/src/app/(app)/w/[slug]/issues/page.tsx @@ -515,7 +515,7 @@ export default function IssuesPage() { } action={ - } diff --git a/src/app/(app)/w/[slug]/projects/[id]/page.tsx b/src/app/(app)/w/[slug]/projects/[id]/page.tsx index a5f74e7e..dec631e1 100644 --- a/src/app/(app)/w/[slug]/projects/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/projects/[id]/page.tsx @@ -154,6 +154,7 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st variant="ember" size="sm" data-quick-create + data-quick-create-mode="issue" data-quick-create-project={project.id} > New issue diff --git a/src/app/(app)/w/[slug]/settings/agents/page.tsx b/src/app/(app)/w/[slug]/settings/agents/page.tsx index f6d61783..6ca84b18 100644 --- a/src/app/(app)/w/[slug]/settings/agents/page.tsx +++ b/src/app/(app)/w/[slug]/settings/agents/page.tsx @@ -98,14 +98,14 @@ export default function AgentsBindingPage() {

    - Profiles and bindings live in Mission Control. This page only changes how a bound - agent works inside {ws.name}. + Manage identities and workspace access in Agents. This page controls how an + authorized agent works inside {ws.name}.

    @@ -121,12 +121,12 @@ export default function AgentsBindingPage() { {/* Bound agents */}
    0 ? ( - {boundRows.length} bound + {boundRows.length} with access ) : undefined } @@ -135,8 +135,8 @@ export default function AgentsBindingPage() { ) : ( diff --git a/src/app/(app)/w/[slug]/settings/agents/request-profile-dialog.tsx b/src/app/(app)/w/[slug]/settings/agents/request-profile-dialog.tsx index 621e7ca1..fa17b88c 100644 --- a/src/app/(app)/w/[slug]/settings/agents/request-profile-dialog.tsx +++ b/src/app/(app)/w/[slug]/settings/agents/request-profile-dialog.tsx @@ -4,7 +4,10 @@ import { toast } from "sonner"; import { AgentProvider, RunEngine } from "@prisma/client"; import { CenterModal } from "@/components/ui/modal"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/ui/combobox"; import { trpc } from "@/lib/trpc"; +import { personalAgentCreationCopy } from "@/lib/agent-ownership-ui"; /** * Member-facing "Request a profile" form launched from Mission Control. @@ -17,11 +20,17 @@ import { trpc } from "@/lib/trpc"; export function RequestProfileDialog({ open, onOpenChange, + onCreated, + setupMode, }: { open: boolean; onOpenChange: (open: boolean) => void; + onCreated?: (profile: { id: string; approvedAt: Date | null }) => void; + setupMode?: "external" | "managed"; }) { const utils = trpc.useUtils(); + const policy = trpc.agents.profiles.policy.useQuery(undefined, { enabled: open }); + const creation = personalAgentCreationCopy(policy.data?.personalAgentCreation); const [name, setName] = useState(""); const [profileKey, setProfileKey] = useState(""); const [provider, setProvider] = useState(AgentProvider.HERMES); @@ -41,17 +50,26 @@ export function RequestProfileDialog({ } const request = trpc.agents.profiles.request.useMutation({ - onSuccess: () => { - toast.success("Profile requested — an instance admin will review it."); + onSuccess: (profile) => { + toast.success( + profile.approvedAt + ? "Personal agent created. Add workspace access to continue." + : "Profile requested — an instance admin will review it.", + ); void utils.agents.bindings.catalog.invalidate(); void utils.agents.profiles.list.invalidate(); reset(); onOpenChange(false); + onCreated?.(profile); }, onError: (e) => toast.error(e.message), }); function submit() { + if (!creation.enabled) { + toast.error("Personal agent creation is not available under the current instance policy."); + return; + } if (!name.trim() || !profileKey.trim()) { toast.error("Name and profile key are required."); return; @@ -64,7 +82,7 @@ export function RequestProfileDialog({ name: name.trim(), profileKey: profileKey.trim(), provider, - runEngine: runEngine === "DEFAULT" ? null : runEngine, + runEngine: setupMode === "external" || runEngine === "DEFAULT" ? null : runEngine, description: description.trim() || undefined, avatar: avatar.trim() || undefined, baseCapabilities, @@ -79,11 +97,35 @@ export function RequestProfileDialog({ onOpenChange(v); }} size="md" - title="Request an agent profile" - description="Profiles are defined globally and need an instance admin's approval before they can be bound to a workspace. Submit the definition essentials here — you'll see it in the catalog once it's approved." - primaryLabel="Submit request" + title={creation.label} + description={creation.description} + primaryLabel={ + policy.data?.personalAgentCreation === "SELF_SERVICE" ? "Create my agent" : "Submit request" + } onPrimary={submit} - loading={request.isPending} + loading={request.isPending || policy.isLoading} + footer={ +
    + + +
    + } >
    @@ -106,32 +148,36 @@ export function RequestProfileDialog({ /> - - - - + onChange={(value) => { + if (value) setProvider(value as AgentProvider); + }} + options={Object.values(AgentProvider).map((value) => ({ + value, + label: value.toLowerCase(), + }))} + className="h-9 w-full" + /> + {setupMode !== "external" && ( + + setRunEngine((value ?? "DEFAULT") as "DEFAULT" | RunEngine)} + options={[ + { value: "DEFAULT", label: "Default" }, + ...Object.values(RunEngine).map((value) => ({ + value, + label: value.toLowerCase(), + })), + ]} + className="h-9 w-full" + /> + + )} setAvatar(e.target.value)} placeholder="🤖" /> diff --git a/src/app/(app)/w/[slug]/settings/runtimes/[id]/page.tsx b/src/app/(app)/w/[slug]/settings/runtimes/[id]/page.tsx index 3a17c969..5bac7dd1 100644 --- a/src/app/(app)/w/[slug]/settings/runtimes/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/settings/runtimes/[id]/page.tsx @@ -1,14 +1,13 @@ "use client"; import { useEffect, useState } from "react"; import Link from "next/link"; -import { notFound, useParams } from "next/navigation"; +import { notFound, useParams, useRouter } from "next/navigation"; import { toast } from "sonner"; import { Bot, ChevronLeft, Cloud, Globe, HardDrive, Server } from "lucide-react"; import type { RuntimeKind } from "@prisma/client"; import { Topbar } from "@/components/topbar"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Avatar } from "@/components/ui/avatar"; import { Confirm, QuickForm } from "@/components/ui/modal"; import { Card } from "@/components/settings/card"; import { Section, SkeletonList } from "@/components/ui"; @@ -16,6 +15,8 @@ import { CodeBlock } from "@/components/mcp-integration-blocks"; import { RuntimeToolSurfacePanel } from "@/components/runtime-tool-surface"; import { RuntimeCredentials } from "@/components/settings/runtime-credentials"; import { RuntimeProvisioning } from "@/components/settings/runtime-provisioning"; +import { RuntimeLifecycleActions } from "@/components/settings/runtime-lifecycle"; +import { runtimeOwnershipLabel } from "@/lib/runtime-ownership"; import { RuntimeSelfTestBadge, RuntimeSelfTestLine, @@ -46,6 +47,7 @@ function agentPresenceText(input: Date | string | null | undefined): string { */ export default function RuntimeDetailPage() { const ws = useWorkspace(); + const router = useRouter(); const params = useParams<{ id: string }>(); const id = params?.id ?? ""; const utils = trpc.useUtils(); @@ -255,19 +257,8 @@ export default function RuntimeDetailPage() { connected {relativeTime(runtime.connectedAt)} ago )} - {runtime.owner && ( - <> - · - - - {runtime.owner.name ?? "unknown"} - - - )} + · + {runtimeOwnershipLabel(runtime)}
    @@ -334,6 +325,17 @@ export default function RuntimeDetailPage() {
    +
    + +
    +
    Owner
    {runtimeOwnershipLabel(runtime)}
    +
    Registration home
    {ws.name}
    +
    +

    The registration home stores this runtime's configuration. It does not define who owns the host or grant access to other workspaces.

    + {runtime.canManageLifecycle ? { if (mode !== "prepare") router.push("/admin/runtimes"); }} /> :

    Instance administrators can transfer ownership or remove an unused registration after reviewing its dependencies.

    } +
    +
    + - {rt.owner && ( - <> - · - - - {rt.owner.name ?? "unknown"} - - - )} + · + {runtimeOwnershipLabel(rt)} + · + Registered in {ws.name}
    { + onSuccess: async (updated) => { + // Keep the form's dirty baseline in step with the visible navigation. + // A quick switch back must not compare against the previous snapshot. + utils.workspace.current.setData(undefined, (previous) => + previous ? { ...previous, ...updated } : previous, + ); toast.success("Workspace updated."); updateWorkspaceContext({ name: updated.name, @@ -152,10 +157,7 @@ export default function WorkspaceSettingsPage() { // tRPC cache. Update it from the committed mutation result above, then // refresh the RSC tree so server-owned dashboard branches agree too. router.refresh(); - void Promise.all([ - utils.workspace.current.invalidate(), - utils.workspace.list.invalidate(), - ]); + await Promise.all([utils.workspace.current.invalidate(), utils.workspace.list.invalidate()]); }, onError: (e) => toast.error(e.message), }); @@ -461,51 +463,53 @@ export default function WorkspaceSettingsPage() { title="Personal" description="A calm Today view for tasks, notes, routines, and agent help." onClick={() => setExperienceProfile("PERSONAL")} - disabled={!canEdit} + disabled={!canEdit || update.isPending} /> setExperienceProfile("TEAM")} - disabled={!canEdit} + disabled={!canEdit || update.isPending} />
    - {experienceProfile === "TEAM" &&
    - - - setCycleLengthDays(Number(e.target.value) || 1)} - disabled={!canEdit} - /> - - - setCycleCooldownDays(Number(e.target.value) || 0)} - disabled={!canEdit} - /> - - -
    } + {experienceProfile === "TEAM" && ( +
    + + + setCycleLengthDays(Number(e.target.value) || 1)} + disabled={!canEdit} + /> + + + setCycleCooldownDays(Number(e.target.value) || 0)} + disabled={!canEdit} + /> + + +
    + )}
    + + + ); +} diff --git a/src/components/action-requests/action-request-card.tsx b/src/components/action-requests/action-request-card.tsx index 8df7a5a0..7c7354d6 100644 --- a/src/components/action-requests/action-request-card.tsx +++ b/src/components/action-requests/action-request-card.tsx @@ -142,6 +142,7 @@ function ActionRequestCardForRequest({ const settle = () => { void utils.actionRequest.get.invalidate({ id: requestId }); void utils.actionRequest.list.invalidate(); + void utils.actionRequest.forComment.invalidate(); void utils.issue.byId.invalidate({ id: issueId }); void utils.commandCenter.summary.invalidate(); void utils.commandCenter.decisionsCount.invalidate(); @@ -251,6 +252,8 @@ function ActionRequestCardForComment({ utils.issue.activity.invalidate({ issueId }); utils.agentRun.activeForIssue.invalidate({ issueId }); utils.actionRequest.forComment.invalidate({ commentId }); + utils.actionRequest.list.invalidate(); + utils.actionRequest.get.invalidate(); }, }); @@ -279,6 +282,8 @@ function ActionRequestCardForComment({ }, onSuccess: () => { utils.actionRequest.forComment.invalidate({ commentId }); + utils.actionRequest.list.invalidate(); + utils.actionRequest.get.invalidate(); setShowDeclineReason(false); setDeclineReason(""); }, diff --git a/src/components/activity-drawer.tsx b/src/components/activity-drawer.tsx index 34d73721..8a9be5e0 100644 --- a/src/components/activity-drawer.tsx +++ b/src/components/activity-drawer.tsx @@ -1,4 +1,5 @@ "use client"; +import { ReminderInbox } from "@/components/reminders/reminder-inbox"; import { useCallback, @@ -237,6 +238,7 @@ export function useActivityDrawer(): { enabled: Boolean(ws), refetchOnWindowFocus: true, staleTime: 30_000, + refetchInterval: 30_000, }); const utils = trpc.useUtils(); @@ -1111,6 +1113,7 @@ export default function ActivityDrawer() { const [tab, setTab] = useState("mine"); const [hasOpenedOnce, setHasOpenedOnce] = useState(false); + const [reminderCount, setReminderCount] = useState(0); const [mineOnly, setMineOnly] = useState(false); const [cursor, setCursor] = useState(undefined); const [pages, setPages] = useState([]); @@ -1345,7 +1348,8 @@ export default function ActivityDrawer() { ? mineData.counts.assignedUnblocked + mineData.counts.mentions + mineData.counts.stalled : 0) + attentionRows.length + - needsInputCount; + needsInputCount + + reminderCount; return (
    + {tab === "mine" && ( + + )} {tab === "mine" ? ( 0} ws={ws} data={mineData} isLoading={mineLoading} @@ -1596,6 +1609,7 @@ type ActionRequestItem = inferRouterOutputs["inbox"]["actionRequestsForMe"]["items"][number]; function MinePanel({ + hasReminders = false, ws, data, isLoading, @@ -1612,6 +1626,7 @@ function MinePanel({ alertsMutatingId, onNavigate, }: { + hasReminders?: boolean; ws: { slug: string }; data: InboxData | undefined; isLoading: boolean; @@ -1666,6 +1681,7 @@ function MinePanel({ ); } if (!data && actionRequests.length === 0 && waitingOnMe.length === 0 && alertRows.length === 0) { + if (hasReminders) return null; return (
    Instance policy - Approve requests, control instance sharing, and force-disable profiles. Routine identity, - binding, archive, and removal work lives in Mission Control. + Approve requests, control instance catalog availability, and disable agents. Manage agent + identities, workspace access, archival, and removal in Agents. +
    +
    +
    {/* Pending profile requests — members request, admins approve/reject. */} {(pending.isLoading || (pending.data?.length ?? 0) > 0) && ( @@ -184,7 +189,7 @@ export function AdminAgents() { Profile Provider Runtime - Bindings + Workspaces Governance
    {profiles.isLoading ? ( @@ -238,6 +243,9 @@ export function AdminAgents() { @{p.profileKey} + + {agentOwnerLabel(p)} + diff --git a/src/components/admin-shell/admin-runtimes.tsx b/src/components/admin-shell/admin-runtimes.tsx index eacb8295..5d99d986 100644 --- a/src/components/admin-shell/admin-runtimes.tsx +++ b/src/components/admin-shell/admin-runtimes.tsx @@ -1,5 +1,10 @@ "use client"; +import { useState } from "react"; +import Link from "next/link"; import { trpc } from "@/lib/trpc"; +import { runtimeOwnershipLabel } from "@/lib/runtime-ownership"; +import { runtimeDisplayIdentity } from "@/lib/transport-display"; +import { RuntimeLifecycleActions } from "@/components/settings/runtime-lifecycle"; import { RuntimeToolSurfaceBadges } from "@/components/runtime-tool-surface"; import { RuntimeSelfTestLine } from "@/components/settings/runtime-self-test"; import { ADMIN, AdminPanel, AdminLoading, AdminEmpty } from "./admin-ui"; @@ -11,21 +16,39 @@ import { ADMIN, AdminPanel, AdminLoading, AdminEmpty } from "./admin-ui"; * multi-workspace restructure. */ export function AdminRuntimes() { + const [includeArchived, setIncludeArchived] = useState(false); const runtimes = trpc.instanceAdmin.runtimes.useQuery(); - const online = runtimes.data?.filter((r) => r.online).length ?? 0; + const accessibleWorkspaces = trpc.workspace.list.useQuery(); + const visibleRuntimes = runtimes.data?.filter( + (runtime) => includeArchived || !runtime.archivedAt, + ); + const online = visibleRuntimes?.filter((runtime) => runtime.online).length ?? 0; return ( -
    +
    +
    +

    + Review ownership separately from registration home and workspace access. +

    + +
    Runtime - Kind + Connection Tool surface Owner Agents @@ -34,10 +57,20 @@ export function AdminRuntimes() {
    {runtimes.isLoading ? ( - ) : !runtimes.data?.length ? ( + ) : runtimes.isError ? ( + + Runtime registrations could not be loaded.{" "} + + + ) : !visibleRuntimes?.length ? ( No runtimes registered. ) : ( - runtimes.data.map((r, i, arr) => { + visibleRuntimes.map((r, i, arr) => { const status = r.health.label; const statusColor = r.health.tone === "success" @@ -50,58 +83,110 @@ export function AdminRuntimes() { return (
    - - - {r.name} - {r.instanceShared && ( +
    + + + {r.name} + {r.archivedAt && ( + Archived + )} + {r.instanceShared && ( + + instance-available + + )} + + {r.adapterKey && ( - shared + {r.adapterKey} )} - - {r.adapterKey && ( - - {r.adapterKey} + + {r.health.reason} - )} - - {r.health.reason} + - + {runtimeDisplayIdentity(r).runtimeLabel} + + - - - {r.kind.toLowerCase()} - - - - {r.owner?.name ?? r.owner?.email ?? "—"} - - - {r.boundAgents} - - - {r.health.lastSignal} - - - - - {status} + + {runtimeOwnershipLabel(r)} + + + Agents + {r.boundAgents} + + + {r.health.lastSignal} - + + + + {status} + + +
    +
    + {accessibleWorkspaces.data?.some( + (workspace) => workspace.id === r.workspace.id, + ) ? ( + + Registration home: {r.workspace.name} · settings + + ) : ( + + Registration home: {r.workspace.name} · host settings require workspace access + + )} + { + if (mode === "prepare") setIncludeArchived(true); + }} + /> +
    ); }) diff --git a/src/components/admin-shell/admin-shell.tsx b/src/components/admin-shell/admin-shell.tsx index f86f1cbe..8f2db464 100644 --- a/src/components/admin-shell/admin-shell.tsx +++ b/src/components/admin-shell/admin-shell.tsx @@ -29,9 +29,9 @@ import { VersionChip } from "@/components/global-shell/version-chip"; const ADMIN_NAV: { href: string; icon: LucideIcon; label: string; hint: string }[] = [ { href: "/admin", icon: LayoutDashboard, label: "Overview", hint: "Health & license" }, - { href: "/admin/tenants", icon: Layers, label: "Workspaces", hint: "All tenants" }, + { href: "/admin/tenants", icon: Layers, label: "Workspaces", hint: "Instance directory" }, { href: "/admin/move-issues", icon: MoveRight, label: "Move issues", hint: "Between workspaces" }, - { href: "/admin/users", icon: Users, label: "Users", hint: "Across instance" }, + { href: "/admin/users", icon: Users, label: "People", hint: "Accounts & instance roles" }, { href: "/admin/auth", icon: KeyRound, @@ -72,7 +72,7 @@ function AdminSidebar({ activePath, instanceUrl }: { activePath: string; instanc className="truncate text-sm font-semibold tracking-tight" style={{ color: "hsl(var(--admin-text))" }} > - Instance admin + Administration
    {instanceUrl} @@ -118,7 +118,7 @@ function AdminSidebar({ activePath, instanceUrl }: { activePath: string; instanc style={{ color: "hsl(var(--admin-text-soft))" }} > - Back to Mission Control + Back to Home
    @@ -192,7 +192,7 @@ export function AdminPage({ style={{ background: "rgba(217,119,87,0.16)", color: "hsl(var(--ember))" }} > - Instance scope · writes affect every tenant + Instance administration · review the scope of each change Instance scope
    diff --git a/src/components/admin-shell/personal-agent-policy.tsx b/src/components/admin-shell/personal-agent-policy.tsx new file mode 100644 index 00000000..71cd9684 --- /dev/null +++ b/src/components/admin-shell/personal-agent-policy.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/ui/combobox"; +import { Confirm } from "@/components/ui/modal"; +import { + type PersonalAgentCreationPolicy, + personalAgentCreationCopy, +} from "@/lib/agent-ownership-ui"; +import { AdminLoading, AdminPanel } from "./admin-ui"; + +export function PersonalAgentPolicy() { + const policy = trpc.agents.profiles.policy.useQuery(); + const [draft, setDraft] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const utils = trpc.useUtils(); + const save = trpc.agents.profiles.setPolicy.useMutation({ + onSuccess: async () => { + await utils.agents.profiles.policy.invalidate(); + setDraft(null); + setConfirmOpen(false); + toast.success("Personal agent creation policy updated."); + }, + onError: (error) => toast.error(error.message), + }); + const value = draft ?? policy.data?.personalAgentCreation; + return ( + + {policy.isLoading ? ( + + ) : policy.isError ? ( +

    + Could not load creation policy. Refresh before changing it. +

    + ) : ( +
    +
    + setDraft(next as PersonalAgentCreationPolicy)} + options={[ + { value: "DISABLED", label: "Disabled", secondary: "Admins create identities" }, + { + value: "APPROVAL_REQUIRED", + label: "Approval required", + secondary: "Members request · admins review", + }, + { + value: "SELF_SERVICE", + label: "Self-service", + secondary: "Users create their own agents", + }, + ]} + className="min-w-64" + /> + +
    +

    + {personalAgentCreationCopy(value).description} +

    +

    + This controls new personal identities only. Existing identities and pending requests + remain unchanged. Service identities, catalog sharing, workspace access, and credentials + retain their separate administrator controls. +

    +
    + )} + { + if (draft) await save.mutateAsync({ personalAgentCreation: draft }); + }} + /> +
    + ); +} diff --git a/src/components/attachments/issue-attachments-panel.tsx b/src/components/attachments/issue-attachments-panel.tsx index 123fbddc..da8e0d81 100644 --- a/src/components/attachments/issue-attachments-panel.tsx +++ b/src/components/attachments/issue-attachments-panel.tsx @@ -11,6 +11,8 @@ import { Trash2, Loader2, AlertTriangle, + ChevronDown, + ChevronUp, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Confirm } from "@/components/ui/modal"; @@ -127,17 +129,21 @@ export function IssueAttachmentsPanel({ issueId }: { issueId: string }) { // because `attachment.list` itself only hits the DB and can't surface // the misconfig — only S3-touching endpoints can. const [tileStorageMisconfig, setTileStorageMisconfig] = useState(false); + const [expanded, setExpanded] = useState(false); const initUpload = trpc.attachment.initUpload.useMutation(); const finalize = trpc.attachment.finalize.useMutation(); const attachLink = trpc.attachment.attachLink.useMutation({ - onSuccess: () => { + onSuccess: (result) => { utils.attachment.list.invalidate({ targetType: "issue", targetId: issueId, }); utils.github.listLinked.invalidate({ issueId }); utils.issue.byId.invalidate({ id: issueId }); + if ("routedTo" in result && result.routedTo === "github.link") { + toast.success("GitHub link added to Automation & delivery."); + } }, onError: (e) => toast.error(e.message), }); @@ -252,6 +258,7 @@ export function IssueAttachmentsPanel({ issueId }: { issueId: string }) { ); const rows = (data ?? []) as Attachment[]; + const visibleRows = expanded ? rows : rows.slice(0, 4); const hasAnything = rows.length > 0 || pending.length > 0; // The list query is DB-only so it won't trip on storage misconfig. @@ -265,13 +272,16 @@ export function IssueAttachmentsPanel({ issueId }: { issueId: string }) { return (
    -
    +

    Attachments @@ -296,7 +306,7 @@ export function IssueAttachmentsPanel({ issueId }: { issueId: string }) { className="h-6 px-2 text-[0.6875rem]" onClick={() => inputRef.current?.click()} > - Upload + Add file

    {linkFormOpen && ( -
    + )} - {storageMisconfig ? ( - + {storageMisconfig && } + {listQuery.isError ? ( +
    + Attachments could not be loaded. + +
    ) : isLoading ? (

    Loading attachments…

    ) : !hasAnything ? ( -

    - Drop files here, paste from clipboard, or click Upload. +

    + No files attached. Drop files here or choose Add file.

    ) : ( -
      - {rows.map((a) => ( +
        + {visibleRows.map((a) => ( )} + {rows.length > 4 && !listQuery.isError && ( + + )}
    ); @@ -517,7 +566,7 @@ function AttachmentTile({ const body = useMemo(() => { if (isLink) { return ( -
    +
    @@ -541,28 +590,26 @@ function AttachmentTile({
    ); } - if (isImage && thumbUrl) { - return ( - // Presigned GET into an ; `object-fit: cover` for the thumb. - // eslint-disable-next-line @next/next/no-img-element - {attachment.filename} - ); - } return ( -
    - {isPdf(attachment.mimeType) ? ( - - ) : ( - - )} -
    {attachment.filename}
    -
    - {prettyBytes(attachment.size)} +
    + + {isImage && thumbUrl ? ( + // Presigned thumbnail; the file name stays visible beside it. + // eslint-disable-next-line @next/next/no-img-element + + ) : isPdf(attachment.mimeType) ? ( + + ) : ( + + )} + +
    +
    + {attachment.filename} +
    +
    + {prettyBytes(attachment.size)} · Preview +
    ); @@ -578,15 +625,11 @@ function AttachmentTile({ ]); return ( -
  • +
  • - {/* Overlay footer — filename only on image thumbs (covers the image). */} - {isImage && ( -
    - {attachment.filename} -
    - )} {canDelete && ( @@ -621,7 +659,11 @@ function AttachmentTile({ onOpenChange={setConfirmOpen} variant="destructive" title="Delete attachment?" - description="Permanently removes the file from the issue and object storage." + description={ + isLink + ? "Removes this external reference from the issue." + : "Permanently removes the file from the issue and object storage." + } primaryLabel="Delete attachment" typeToConfirm={attachment.filename} onConfirm={() => onDelete()} diff --git a/src/components/breadcrumbs.tsx b/src/components/breadcrumbs.tsx index 1e2b2b5b..8f582b76 100644 --- a/src/components/breadcrumbs.tsx +++ b/src/components/breadcrumbs.tsx @@ -79,7 +79,7 @@ export function Breadcrumbs() { case "issue-id": { const d = issueByIdQ.data; if (!d) return "…"; - return formatIssueId(parsed.workspaceKey ?? "", d.number); + return ws ? formatIssueId(ws.key, d.number) : "…"; } case "issue-key": return parsed.entityKey; @@ -105,6 +105,8 @@ export function Breadcrumbs() { if (!parsed || !slug) return null; const sectionHref = parsed.sectionHref ? `/w/${slug}${parsed.sectionHref}` : null; + const issueTitle = parsed.entityKind === "issue-id" ? issueByIdQ.data?.title : null; + const fullLabel = issueTitle ? `${entityLabel} · ${issueTitle}` : entityLabel; return (
  • {railCollapsed && ( @@ -183,6 +187,7 @@ export function PersonalDashboard() { )} +
    @@ -208,7 +213,7 @@ export function PersonalDashboard() { { value: "SOMEDAY", label: "Someday" }, ]} ariaLabel="Task due date" - className="hidden h-8 text-xs text-muted-foreground sm:inline-flex" + className="h-8 text-xs text-muted-foreground" />
    + {buckets.someday.length > 0 && ( + doneStatus && completeTask.mutate({ id, statusId: doneStatus.id })} + canComplete={Boolean(doneStatus)} + /> + )} + + View all work + + {active.data?.nextCursor && ( +

    + Showing the 100 most recently updated open items. View all work for the full list. +

    + )}
    )} diff --git a/src/components/global-shell/global-attention-drawer.tsx b/src/components/global-shell/global-attention-drawer.tsx index bbe44f9b..21495f5f 100644 --- a/src/components/global-shell/global-attention-drawer.tsx +++ b/src/components/global-shell/global-attention-drawer.tsx @@ -8,6 +8,7 @@ import { EmptyState, Spinner } from "@/components/ui"; import { trpc } from "@/lib/trpc"; import { relativeTime } from "@/lib/utils"; import { WsChip } from "./mission-control-home"; +import { ReminderInbox } from "@/components/reminders/reminder-inbox"; /** Cross-workspace counterpart to the workspace ActivityDrawer. */ export function GlobalAttentionBell() { @@ -85,6 +86,7 @@ export function GlobalAttentionBell() {
    + setOpen(false)} /> {work.isLoading || activity.isLoading || alerts.isLoading ? (
    @@ -97,7 +99,7 @@ export function GlobalAttentionBell() {

    Operational alerts

    - {alertCount.data?.count ?? 0} unread + {alertCount.data?.alerts ?? 0} unread
    diff --git a/src/components/global-shell/global-operations-model.ts b/src/components/global-shell/global-operations-model.ts index 74b38297..c6fa9b08 100644 --- a/src/components/global-shell/global-operations-model.ts +++ b/src/components/global-shell/global-operations-model.ts @@ -14,11 +14,13 @@ export function deriveGlobalOperationsPosture(input: { }): GlobalOperationsPosture { if (input.runtimeCount === 0) { return { - tone: "warning", - label: "Setup required", - summary: "Register a runtime before agents can execute queued work.", - actionLabel: "Register a runtime", - actionHref: "/settings/runtimes", + tone: input.activeRuns > 0 ? "success" : "neutral", + label: input.activeRuns > 0 ? "Work in motion" : "Managed execution optional", + summary: input.activeRuns > 0 + ? `${input.activeRuns} active runs. No managed runtime is registered; external MCP work does not require one.` + : "External MCP clients can work without a runtime. Configure managed execution only when Forge should dispatch work.", + actionLabel: "Set up an agent", + actionHref: "/agents", }; } diff --git a/src/components/global-shell/global-shell.tsx b/src/components/global-shell/global-shell.tsx index f66565ed..3071fc83 100644 --- a/src/components/global-shell/global-shell.tsx +++ b/src/components/global-shell/global-shell.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { Sparkles, + Home, Bot, Inbox, Activity, @@ -120,9 +121,10 @@ export interface GlobalBreadcrumb { } const GLOBAL_NAV: { href: string; icon: LucideIcon; label: string; hint: string }[] = [ - { href: "/", icon: Sparkles, label: "Mission Control", hint: "Across all workspaces" }, - { href: "/agents", icon: Bot, label: "Agents", hint: "Fleet & profiles" }, - { href: "/inbox", icon: Inbox, label: "Inbox", hint: "Mentions & assignments" }, + { href: "/", icon: Home, label: "Home", hint: "My work & workspaces" }, + { href: "/mission-control", icon: Sparkles, label: "Mission Control", hint: "Agent operations & delivery" }, + { href: "/agents", icon: Bot, label: "Agents", hint: "Identity, access & connections" }, + { href: "/inbox", icon: Inbox, label: "Inbox", hint: "Assigned work & context" }, { href: "/activity", icon: Activity, label: "Activity", hint: "Live run feed" }, ]; @@ -199,6 +201,7 @@ function NavRow({ return (
    - + Workspaces - + @@ -323,7 +327,7 @@ function GlobalNavContent({ )} > - Settings + My account , @@ -340,9 +344,9 @@ function GlobalNavContent({ )} > - Instance admin + Administration - root + admin )} @@ -429,7 +433,7 @@ function GlobalTopBar({ {scope === "control" ? : } - {scope === "control" ? "Global control plane" : "Read-only across workspaces"} + {scope === "control" ? "Manage resources you can access" : "Your accessible workspaces"}
    diff --git a/src/components/global-shell/mission-control-home.tsx b/src/components/global-shell/mission-control-home.tsx index 98240646..8ea5f219 100644 --- a/src/components/global-shell/mission-control-home.tsx +++ b/src/components/global-shell/mission-control-home.tsx @@ -321,7 +321,7 @@ export function MissionControlHome() { eyebrow="Queue" title="Workspaces needing attention" count={snapshot ? `${snapshot.openIssues} open` : undefined} - action={Manage} + action={Browse} className="lg:col-span-8" > {workspaces.isLoading ? ( @@ -453,7 +453,7 @@ export function MissionControlHome() { eyebrow="Coverage" title="Agent presence" count={agents.data ? `${onlineAgents} online · ${agentsList.length} total` : undefined} - action={Manage fleet} + action={Manage agents} className="lg:col-span-5" > {agents.isLoading ? ( @@ -491,7 +491,7 @@ export function MissionControlHome() { {agent.bindings.length === 0 - ? "No workspace bindings" + ? "No workspace access" : agent.bindings.map((binding) => binding.workspace.name).join(" · ")} @@ -529,13 +529,13 @@ export function MissionControlHome() {

    No runtimes registered

    - Register a host before agents can execute queued work. + Add a host for Forge-managed execution. External MCP clients do not need one.

    - Register runtime + Set up an agent
    ) : ( diff --git a/src/components/global-shell/personal-home.tsx b/src/components/global-shell/personal-home.tsx new file mode 100644 index 00000000..3f1d499a --- /dev/null +++ b/src/components/global-shell/personal-home.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { ArrowRight, CheckCircle2, Inbox, Layers } from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Card, EmptyState, Section, SkeletonList } from "@/components/ui"; +import { WorkspaceBadge, type GlobalShellWorkspace } from "./global-shell"; +import { ReminderInbox } from "@/components/reminders/reminder-inbox"; + +/** Personal entry point: uses the same permission-filtered assignments as operations. */ +export function PersonalHome({ workspaces }: { workspaces: GlobalShellWorkspace[] }) { + const work = trpc.global.work.useQuery(); + const [expanded, setExpanded] = useState(false); + const assignments = work.data ?? []; + const visible = expanded ? assignments : assignments.slice(0, 8); + + return ( +
    +
    + Open inbox → + + } + > + + {work.isLoading ? ( + + ) : work.isError ? ( + void work.refetch()}> + Try again + + } + /> + ) : assignments.length === 0 ? ( + } + title="No open assignments" + description="Open a workspace to create work or pick up an issue." + /> + ) : ( +
      + {visible.map((issue) => ( +
    • + + + + {issue.title} + + + {issue.workspace.key}-{issue.number} + + {issue.workspace.name} + {issue.status.name} + + + + +
    • + ))} +
    + )} + {assignments.length > 8 && ( +
    + +
    + )} + {assignments.length === 30 && ( +

    + Showing your 30 most recent assignments. Open a workspace to see its full issue list. +

    + )} +
    +
    +
    + +
    + Browse all → + + } + > + + {workspaces.length === 0 ? ( + } + title="Make room for your work" + description="Create a workspace or ask its owner for an invitation." + action={ + + Explore workspaces → + + } + /> + ) : ( +
      + {workspaces.map((workspace) => ( +
    • + + + + {workspace.name} + + + +
    • + ))} +
    + )} +
    +
    +
    + + + + + Inbox + + Inspect your assignments and their latest context. + + + + + Recent workspace activity → + + +
    +
    +
    + ); +} diff --git a/src/components/global-shell/workspaces-content.tsx b/src/components/global-shell/workspaces-content.tsx new file mode 100644 index 00000000..589822c8 --- /dev/null +++ b/src/components/global-shell/workspaces-content.tsx @@ -0,0 +1,404 @@ +"use client"; +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Plus, ArrowRight, UserRound, UsersRound } from "lucide-react"; +import { Topbar } from "@/components/topbar"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog } from "@/components/ui/dialog"; +import { Card, EmptyState, MOTION, Section, SkeletonList } from "@/components/ui"; +import { trpc } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; +import { workspaceColor } from "@/lib/workspace-color"; + +/** + * Workspace directory. Lives at `/workspaces` (not + * under any `/w/[slug]`) so it remains reachable when the user has no + * active workspace. Workspace-scoped settings (rename, sprint length, danger + * zone) live under `/w/[slug]/settings/workspace`; this page is about + * moving *between* workspaces plus creating new ones. + * + * `workspace.create` seeds profile-aware labels and defaults. Personal + * workspaces intentionally skip the initial sprint. + */ +export default function WorkspacesContent() { + const router = useRouter(); + const { data: workspaces, refetch, isLoading, isError } = trpc.workspace.list.useQuery(); + // Cross-workspace mini-stats (members / openIssues / agents / activeRuns), + // keyed by id so each row can show its tiles. Loads alongside the list; + // tiles render once it resolves (rows still work without it). + const { data: stats } = trpc.global.workspaces.useQuery(); + const statsById = useMemo( + () => new Map((stats ?? []).map((s) => [s.id, s])), + [stats], + ); + const setLast = trpc.workspace.setLastWorkspace.useMutation(); + const [createOpen, setCreateOpen] = useState(false); + + function open(w: { id: string; slug: string }) { + setLast.mutate({ workspaceId: w.id }); + router.push(`/w/${w.slug}/inbox`); + } + + return ( + <> + setCreateOpen(true)}> + + Create workspace + + } + /> +
    +
    +
    + {isLoading ? ( + + ) : isError ? ( + void refetch()}>Try again} /> + ) : ( + + {(workspaces?.length ?? 0) === 0 && ( + } + title="No workspaces yet" + description="Create one to get started." + action={ + + } + /> + )} + {workspaces?.map((w) => { + const c = workspaceColor(w.key); + const role = w.memberships[0]?.role ?? "MEMBER"; + const st = statsById.get(w.id); + return ( +
  • + + {(role === "OWNER" || role === "ADMIN") && ( + + Manage + + )} +
  • + ); + })} +
    + )} +
    + +
    +
    + + setCreateOpen(false)} + onCreated={(slug) => { + refetch(); + router.push(`/w/${slug}/inbox`); + }} + existingKeys={new Set(workspaces?.map((w) => w.key) ?? [])} + existingSlugs={new Set(workspaces?.map((w) => w.slug) ?? [])} + /> + + ); +} + +function CreateDialog({ + open, + onClose, + onCreated, + existingKeys, + existingSlugs, +}: { + open: boolean; + onClose: () => void; + onCreated: (slug: string) => void; + existingKeys: Set; + existingSlugs: Set; +}) { + const [name, setName] = useState(""); + const [key, setKey] = useState(""); + const [slug, setSlug] = useState(""); + const [cycleLengthDays, setCycleLengthDays] = useState(7); + const [timeTrackingEnabled, setTimeTrackingEnabled] = useState(false); + const [experienceProfile, setExperienceProfile] = useState<"TEAM" | "PERSONAL">("TEAM"); + + const create = trpc.workspace.create.useMutation({ + onSuccess: (ws) => { + toast.success("Workspace created."); + reset(); + onClose(); + onCreated(ws.slug); + }, + onError: (e) => toast.error(e.message), + }); + + function reset() { + setName(""); + setKey(""); + setSlug(""); + setCycleLengthDays(7); + setTimeTrackingEnabled(false); + setExperienceProfile("TEAM"); + } + + function onNameChange(v: string) { + setName(v); + if (!slug) { + setSlug( + v + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48), + ); + } + if (!key) { + const suggested = v + .split(/\s+/) + .filter(Boolean) + .slice(0, 3) + .map((w) => w[0]?.toUpperCase() ?? "") + .join(""); + const singleWord = v.replace(/[^a-zA-Z]/g, "").slice(0, 3).toUpperCase(); + if (suggested.length >= 2) setKey(suggested.slice(0, 5)); + else if (singleWord.length >= 2) setKey(singleWord); + } + } + + const issues = useMemo(() => { + const errs: string[] = []; + if (name.trim().length < 1) errs.push("Name is required."); + if (!/^[A-Z]{2,6}$/.test(key)) errs.push("Key must be 2–6 uppercase letters."); + else if (existingKeys.has(key)) errs.push(`Key ${key} is already in use.`); + if (!/^[a-z0-9-]{2,48}$/.test(slug)) + errs.push("Slug must be 2–48 lowercase alphanumeric chars or dashes."); + else if (existingSlugs.has(slug)) errs.push(`Slug ${slug} is already in use.`); + if (experienceProfile === "TEAM" && (cycleLengthDays < 1 || cycleLengthDays > 90)) + errs.push("Sprint length must be between 1 and 90 days."); + return errs; + }, [name, key, slug, cycleLengthDays, experienceProfile, existingKeys, existingSlugs]); + + return ( + + { + e.preventDefault(); + if (issues.length > 0) return; + create.mutate({ + name: name.trim(), + key, + slug, + cycleLengthDays, + timeTrackingEnabled, + experienceProfile, + }); + }} + className="space-y-3 p-5" + > +
    Create workspace
    +

    + The key shows up in issue identifiers like ACME-42 + {" "}and is permanent. Slug appears in URLs; it can be renamed later by an + owner via a data migration. +

    + + +
    + + +
    +
    + + + onNameChange(e.target.value)} + maxLength={80} + autoFocus + aria-label="Workspace name" + /> + +
    + + setKey(e.target.value.replace(/[^a-zA-Z]/g, "").toUpperCase())} + maxLength={6} + placeholder="ACME" + className="font-mono" + aria-label="Workspace key" + /> + + + + setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "")) + } + maxLength={48} + placeholder="acme" + className="font-mono" + aria-label="Workspace slug" + /> + +
    + {experienceProfile === "TEAM" &&
    + + setCycleLengthDays(Number(e.target.value) || 7)} + /> + + + + +
    } + +
    + The key cannot be changed later. Pick something short and stable. +
    + + {issues.length > 0 && ( +
      + {issues.map((m) => ( +
    • · {m}
    • + ))} +
    + )} + +
    + + +
    + +
    + ); +} + +/** Compact per-workspace mini-stat tile (members / open / agents / live). */ +function Stat({ label, value, ember }: { label: string; value: number; ember?: boolean }) { + return ( +
    +
    + {value} +
    +
    + {label} +
    +
    + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
    +
    + {label} +
    + {children} +
    + ); +} diff --git a/src/components/initiatives/new-initiative-dialog.tsx b/src/components/initiatives/new-initiative-dialog.tsx index 257bf6e2..0f50faa8 100644 --- a/src/components/initiatives/new-initiative-dialog.tsx +++ b/src/components/initiatives/new-initiative-dialog.tsx @@ -27,12 +27,14 @@ export function NewInitiativeDialog({ open, onClose, onCreated, + initialName = "", }: { open: boolean; onClose: () => void; onCreated?: () => void; + initialName?: string; }) { - const [name, setName] = useState(""); + const [name, setName] = useState(initialName); const [slug, setSlug] = useState(""); const [description, setDescription] = useState(""); const [targetDate, setTargetDate] = useState(""); diff --git a/src/components/issue-detail/github-link-modal.tsx b/src/components/issue-detail/github-link-modal.tsx index a0c4069c..1b5515fb 100644 --- a/src/components/issue-detail/github-link-modal.tsx +++ b/src/components/issue-detail/github-link-modal.tsx @@ -98,24 +98,26 @@ export function GitHubLinkModal({ issueId, open, onOpenChange, + defaultKind = "RELATES_TO", }: { issueId: string; open: boolean; onOpenChange: (open: boolean) => void; + defaultKind?: LinkKind; }) { const ws = useWorkspace(); const utils = trpc.useUtils(); const isAdmin = ws.role === "OWNER" || ws.role === "ADMIN"; const [tab, setTab] = useState<"url" | "browse">("url"); - const [kind, setKind] = useState("RELATES_TO"); + const [kind, setKind] = useState(defaultKind); // Reset transient state whenever the modal is (re)opened. useEffect(() => { if (open) { setTab("url"); - setKind("RELATES_TO"); + setKind(defaultKind); } - }, [open]); + }, [open, defaultKind]); const onLinked = (closeAfter: boolean) => { void utils.github.listLinked.invalidate({ issueId }); diff --git a/src/components/issue-detail/github-links-panel.tsx b/src/components/issue-detail/github-links-panel.tsx index c89a9a6d..20c5e433 100644 --- a/src/components/issue-detail/github-links-panel.tsx +++ b/src/components/issue-detail/github-links-panel.tsx @@ -53,14 +53,22 @@ function metadataRecord(value: unknown): Record { : {}; } -export function GitHubLinksPanel({ issueId }: { issueId: string }) { +export function GitHubLinksPanel({ + issueId, + embedded = false, +}: { + issueId: string; + embedded?: boolean; +}) { const utils = trpc.useUtils(); const [modalOpen, setModalOpen] = useState(false); - const { data: links, isLoading } = trpc.github.listLinked.useQuery( - { issueId }, - { staleTime: 30_000 }, - ); + const { + data: links, + isLoading, + isError, + refetch, + } = trpc.github.listLinked.useQuery({ issueId }, { staleTime: 30_000 }); const syncM = trpc.github.sync.useMutation({ onSuccess: () => { toast.success("GitHub state synced."); @@ -70,8 +78,15 @@ export function GitHubLinksPanel({ issueId }: { issueId: string }) { onError: (e) => toast.error(e.message), }); + if (embedded && !isLoading && !isError && !links?.length) return null; + return ( -