diff --git a/.agents/skills/add-enrichment/SKILL.md b/.agents/skills/add-enrichment/SKILL.md
index 35ef4596b24..8ac7f58a95c 100644
--- a/.agents/skills/add-enrichment/SKILL.md
+++ b/.agents/skills/add-enrichment/SKILL.md
@@ -22,8 +22,8 @@ Because enrichments run on Sim's hosted keys by default, **every provider tool y
## Architecture (what you're plugging into)
-- **`enrichments/types.ts`** — `EnrichmentConfig { id, name, description, icon, inputs, outputs, providers }` and `EnrichmentProvider { id, label, toolId, buildParams, projectFailure, mapOutput, isVerified? }` — `toolProvider` fills `projectFailure` with the standard HTTP projection; override it only for a provider whose failure shape is nonstandard (see `enrichments/provider-failures/`). Providers are **plain data** (no `@/tools` import) so the catalog stays client-safe.
-- **`enrichments/providers.ts`** — `toolProvider(...)` (typed passthrough) plus shared input helpers: `str(v)`, `normalizeDomain(v)`, `firstNonEmpty(arr)`, `splitName(fullName)`, and `projectEnrichmentProviderFailure`.
+- **`enrichments/types.ts`** — `EnrichmentConfig { id, name, description, icon, inputs, outputs, providers }` and `EnrichmentProvider { id, label, toolId, buildParams, projectFailure, mapOutput, isVerified? }` — `toolProvider` fills `projectFailure` with the standard HTTP projection; override it only for a provider whose failure shape is nonstandard. Providers are **plain data** (no `@/tools` import) so the catalog stays client-safe.
+- **`enrichments/providers.ts`** — `toolProvider(...)` (typed passthrough) plus shared input helpers: `str(v)`, `normalizeDomain(v)`, `splitName(fullName)`, and `projectEnrichmentProviderFailure`.
- **`enrichments/run.ts`** — the server-only cascade runner. Calls `executeTool(provider.toolId, { ...params, _context: { workspaceId, userId } })`, accumulates hosted-key cost, returns the first non-empty mapped result. **You do not edit this** — it works for any registry entry.
- **`enrichments/registry.ts`** — `ENRICHMENT_REGISTRY` / `ALL_ENRICHMENTS` / `getEnrichment`. Register new entries here.
@@ -111,7 +111,7 @@ export { myEnrichment } from './my-enrichment'
```
Rules:
-- Keep the file **client-safe**: import only `@sim/emcn/icons`, `@sim/utils/*`, `@/enrichments/providers`, `@/enrichments/provider-failures/*`, and the types. **Never import `@/tools`** here — the runner does the tool call.
+- Keep the file **client-safe**: import only `@sim/emcn/icons`, `@sim/utils/*`, `@/enrichments/providers`, and the types. **Never import `@/tools`** here — the runner does the tool call.
- `buildParams` returns `null` when inputs are insufficient (provider skipped). `mapOutput` returns `null`/empty for a miss (falls through). Use `filterUndefined` when assembling optional tool params; coerce numbers explicitly (don't pass `''` to number outputs).
- Output `id`s are the keys `mapOutput` returns; output `name`s are the default column names (the user can rename them in the config).
diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md
index 53311498b56..33473112aa0 100644
--- a/.agents/skills/add-trigger/SKILL.md
+++ b/.agents/skills/add-trigger/SKILL.md
@@ -411,7 +411,7 @@ export const {service}PollingHandler: PollingProviderHandler = {
await markWebhookSuccess(webhookId, logger)
return 'success'
} catch (error) {
- logger.error(`[${requestId}] Error processing {service} webhook ${webhookId}:`, error)
+ logger.error('Error processing {service} webhook:', error)
await markWebhookFailed(webhookId, logger)
return 'failure'
}
diff --git a/.claude/rules/sim-platform-views.md b/.claude/rules/sim-platform-views.md
new file mode 100644
index 00000000000..f72b0e18551
--- /dev/null
+++ b/.claude/rules/sim-platform-views.md
@@ -0,0 +1,65 @@
+---
+description: One pattern per job across the organization views (Settings, People, Integrations, Links, Home)
+paths:
+ - "apps/sim/platform/app/(shell)/{settings,people,integrations,links,home}/**"
+ - "apps/sim/platform/design-system/components/{status,modal,picker,settings-section,search-field,activity-row}/**"
+ - "apps/sim/app/o/*/**"
+---
+
+# Organization Views
+
+The organization views live in `apps/sim/platform/app/(shell)/` and take everything shared from
+`@/platform/design-system/components`. Each job below has one pattern. A view that differs needs a
+reason written where it differs; "it was already like that" is not one.
+
+| Job | Use | Never |
+| --- | --- | --- |
+| First read of a view | `QueryBoundary` (`query`, `failure`, `loadingLabel`, optional `empty`, `loading`), which renders `LoadingRows`, then `FailureNotice`, then `empty`, then `children(data)` | Inline `isPending ? … : error ? …`, a raw `
Loading…
`, a loading `InlineNotice` |
+| Loading | `LoadingRows` by default. A placeholder shaped like its content (`WidgetSkeleton`, usage charts) only where rows would misrepresent it | A second generic skeleton |
+| Failed read | `FailureNotice`, labelled Retry. `intent='warning'` where the read partly answered | A hand-built retry button or `InlineNotice` + `Button` |
+| Failed background refresh | `FailureNotice` outside the boundary, over the content it keeps | Replacing content already on screen |
+| Empty | `EmptyState`, with no create button when the page already has one. A picker's no-match line inside a dialog stays `Muted`, since a block would take over the dialog | `Muted` or a raw `` anywhere else |
+| Confirm | `ConfirmDialog` (`target`, `title`, `confirmLabel`, `pendingLabel`, `onConfirm`, `safe` when nothing is lost). Removing a person, revoking an invite and any destructive row action always ask first | `Modal variant='confirm'`, `window.confirm`, an unconfirmed destructive action |
+| Save | `SaveActions`, end-aligned in the page flow under the fields it commits: Discard while dirty, then Save. Save is visible and disabled while clean | Save in a header, Save before Discard, a Cancel beside Discard |
+| Editing a server value | `useServerDraft`. Adopts a newer value while clean; while dirty it keeps the edits and sets `conflict`. Show `DraftConflictNotice`, and block Save until the viewer chooses | Seeding `useState` from a query once |
+| Autosave vs Save | One or the other per page. A page with `SaveActions` holds every field in one draft and writes it in one request. List actions with their own dialog, such as adding or removing a member, are not field edits | A page mixing both |
+| Create | A primary compact button in `SectionToolbar`, or in the page header on a page with one | Create in both the toolbar and the empty state |
+| Search over a section's rows | `SectionSearch subject='members'` | `SearchField` with its own copy, unless the search covers more than the subject's name |
+| Row | `ActivityRow` for a row that opens something, `SettingsRow` for a row holding a control | A hand-built row |
+| List of rows | `RowList` (`dividers` for a list read as one block). `ActivityRow density='comfortable'` in settings | `calc(-1 * var(--ui-row-inset))` around a list; styling a row's height from outside |
+| Row actions | `ActionMenu` with icon `I108`, destructive item last with a separator | The same action in the row and its menu |
+| Section heading | `SettingsSection title` (h2), `DetailHeader` for a sub-view's way back | A hand-written heading |
+| Toolbar | `SectionToolbar grow={ }` with its actions as children | A hand-built flex row |
+
+## Failure feedback
+
+- Form validation is inline, on the field (`error`), once a save has been attempted.
+- A dialog's own action failing stays in the dialog: `Modal` shows a rejected `onConfirm`'s message and keeps the draft.
+- Any other action failing is a toast. Nothing fails silently.
+
+## Page gates
+
+Every page under `app/o/[organizationId]` calls
+`requireOrganizationViewer(organizationId, callbackUrl)` from `@/app/o/[organizationId]/viewer`. It
+redirects a signed-out viewer to sign-in and back, 404s a viewer with no access, and returns
+`{ userId, principal, surface }`. Never write `{ kind: 'session', ... }` in a page.
+
+## Dialogs
+
+`Modal` holds itself open while `onConfirm`'s promise or its `pending` prop runs. Escape, the backdrop
+and Close do nothing until it settles, including when the browser closes the `` itself: once
+user activation is spent, `cancel` cannot be prevented, so `OverlayDialog` reopens from `close`.
+
+## Files
+
+- A section with more than one job splits by job: `group-detail.tsx` is the shell, `group-general-tab.tsx`
+ and `group-catalog-tabs.tsx` its bodies, `use-group-draft.ts` its write.
+- Merge logic worth a test is a pure module next to the view, such as `secrets-draft.ts`.
+- `section-parts/` holds what only settings needs: `SectionStack`, `FieldStack`, `SectionToolbar`,
+ `DetailHeader`, `HeadingRow`, `Muted`, `SectionSkeleton`, `DraftConflictNotice`. `useServerDraft` is in `_components/`, since Integrations uses it too.
+
+## Legacy workspace settings
+
+`apps/sim/app/workspace/*/settings/**` keeps `SettingsPanel` from `@/components/settings/settings-panel`,
+which owns chrome through `settings/[section]/layout.tsx`. The `add-settings-page` skill describes it.
+New organization work never imports it.
diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md
index c39babca114..d10630c69b9 100644
--- a/.claude/rules/sim-settings-pages.md
+++ b/.claude/rules/sim-settings-pages.md
@@ -2,7 +2,6 @@
description: Settings page layout and SettingsPanel design-system compliance
paths:
- "apps/sim/app/workspace/*/settings/**"
- - "apps/sim/app/o/*/settings/**"
- "apps/sim/app/workspace/*/{integrations,skills,upgrade}/**"
- "apps/sim/app/workspace/*/components/{resource-tile,credential-detail}/**"
- "apps/sim/components/{settings,permissions}/**"
diff --git a/.cursor/rules/sim-platform-views.mdc b/.cursor/rules/sim-platform-views.mdc
new file mode 100644
index 00000000000..9cbcc0383a2
--- /dev/null
+++ b/.cursor/rules/sim-platform-views.mdc
@@ -0,0 +1,64 @@
+---
+description: "One pattern per job across the organization views (Settings, People, Integrations, Links, Home)"
+globs: ["apps/sim/platform/app/(shell)/{settings,people,integrations,links,home}/**","apps/sim/platform/design-system/components/{status,modal,picker,settings-section,search-field,activity-row}/**","apps/sim/app/o/*/**"]
+---
+
+
+
+# Organization Views
+
+The organization views live in `apps/sim/platform/app/(shell)/` and take everything shared from
+`@/platform/design-system/components`. Each job below has one pattern. A view that differs needs a
+reason written where it differs; "it was already like that" is not one.
+
+| Job | Use | Never |
+| --- | --- | --- |
+| First read of a view | `QueryBoundary` (`query`, `failure`, `loadingLabel`, optional `empty`, `loading`), which renders `LoadingRows`, then `FailureNotice`, then `empty`, then `children(data)` | Inline `isPending ? … : error ? …`, a raw `Loading…
`, a loading `InlineNotice` |
+| Loading | `LoadingRows` by default. A placeholder shaped like its content (`WidgetSkeleton`, usage charts) only where rows would misrepresent it | A second generic skeleton |
+| Failed read | `FailureNotice`, labelled Retry. `intent='warning'` where the read partly answered | A hand-built retry button or `InlineNotice` + `Button` |
+| Failed background refresh | `FailureNotice` outside the boundary, over the content it keeps | Replacing content already on screen |
+| Empty | `EmptyState`, with no create button when the page already has one. A picker's no-match line inside a dialog stays `Muted`, since a block would take over the dialog | `Muted` or a raw `` anywhere else |
+| Confirm | `ConfirmDialog` (`target`, `title`, `confirmLabel`, `pendingLabel`, `onConfirm`, `safe` when nothing is lost). Removing a person, revoking an invite and any destructive row action always ask first | `Modal variant='confirm'`, `window.confirm`, an unconfirmed destructive action |
+| Save | `SaveActions`, end-aligned in the page flow under the fields it commits: Discard while dirty, then Save. Save is visible and disabled while clean | Save in a header, Save before Discard, a Cancel beside Discard |
+| Editing a server value | `useServerDraft`. Adopts a newer value while clean; while dirty it keeps the edits and sets `conflict`. Show `DraftConflictNotice`, and block Save until the viewer chooses | Seeding `useState` from a query once |
+| Autosave vs Save | One or the other per page. A page with `SaveActions` holds every field in one draft and writes it in one request. List actions with their own dialog, such as adding or removing a member, are not field edits | A page mixing both |
+| Create | A primary compact button in `SectionToolbar`, or in the page header on a page with one | Create in both the toolbar and the empty state |
+| Search over a section's rows | `SectionSearch subject='members'` | `SearchField` with its own copy, unless the search covers more than the subject's name |
+| Row | `ActivityRow` for a row that opens something, `SettingsRow` for a row holding a control | A hand-built row |
+| List of rows | `RowList` (`dividers` for a list read as one block). `ActivityRow density='comfortable'` in settings | `calc(-1 * var(--ui-row-inset))` around a list; styling a row's height from outside |
+| Row actions | `ActionMenu` with icon `I108`, destructive item last with a separator | The same action in the row and its menu |
+| Section heading | `SettingsSection title` (h2), `DetailHeader` for a sub-view's way back | A hand-written heading |
+| Toolbar | `SectionToolbar grow={ }` with its actions as children | A hand-built flex row |
+
+## Failure feedback
+
+- Form validation is inline, on the field (`error`), once a save has been attempted.
+- A dialog's own action failing stays in the dialog: `Modal` shows a rejected `onConfirm`'s message and keeps the draft.
+- Any other action failing is a toast. Nothing fails silently.
+
+## Page gates
+
+Every page under `app/o/[organizationId]` calls
+`requireOrganizationViewer(organizationId, callbackUrl)` from `@/app/o/[organizationId]/viewer`. It
+redirects a signed-out viewer to sign-in and back, 404s a viewer with no access, and returns
+`{ userId, principal, surface }`. Never write `{ kind: 'session', ... }` in a page.
+
+## Dialogs
+
+`Modal` holds itself open while `onConfirm`'s promise or its `pending` prop runs. Escape, the backdrop
+and Close do nothing until it settles, including when the browser closes the `` itself: once
+user activation is spent, `cancel` cannot be prevented, so `OverlayDialog` reopens from `close`.
+
+## Files
+
+- A section with more than one job splits by job: `group-detail.tsx` is the shell, `group-general-tab.tsx`
+ and `group-catalog-tabs.tsx` its bodies, `use-group-draft.ts` its write.
+- Merge logic worth a test is a pure module next to the view, such as `secrets-draft.ts`.
+- `section-parts/` holds what only settings needs: `SectionStack`, `FieldStack`, `SectionToolbar`,
+ `DetailHeader`, `HeadingRow`, `Muted`, `SectionSkeleton`, `DraftConflictNotice`. `useServerDraft` is in `_components/`, since Integrations uses it too.
+
+## Legacy workspace settings
+
+`apps/sim/app/workspace/*/settings/**` keeps `SettingsPanel` from `@/components/settings/settings-panel`,
+which owns chrome through `settings/[section]/layout.tsx`. The `add-settings-page` skill describes it.
+New organization work never imports it.
diff --git a/.cursor/rules/sim-settings-pages.mdc b/.cursor/rules/sim-settings-pages.mdc
index aeb87b3325a..59a148afae9 100644
--- a/.cursor/rules/sim-settings-pages.mdc
+++ b/.cursor/rules/sim-settings-pages.mdc
@@ -1,6 +1,6 @@
---
description: "Settings page layout and SettingsPanel design-system compliance"
-globs: ["apps/sim/app/workspace/*/settings/**","apps/sim/app/o/*/settings/**","apps/sim/app/workspace/*/{integrations,skills,upgrade}/**","apps/sim/app/workspace/*/components/{resource-tile,credential-detail}/**","apps/sim/components/{settings,permissions}/**","apps/sim/ee/**/components/**"]
+globs: ["apps/sim/app/workspace/*/settings/**","apps/sim/app/workspace/*/{integrations,skills,upgrade}/**","apps/sim/app/workspace/*/components/{resource-tile,credential-detail}/**","apps/sim/components/{settings,permissions}/**","apps/sim/ee/**/components/**"]
---
diff --git a/.github/ci-suites.json b/.github/ci-suites.json
index 48b3bf98c08..fbd721966a9 100644
--- a/.github/ci-suites.json
+++ b/.github/ci-suites.json
@@ -24,6 +24,10 @@
"group": "organization-search",
"task": "ci:e2e-organization-search"
},
+ {
+ "group": "org-redesign",
+ "task": "ci:e2e-org-redesign"
+ },
{
"group": "browser-extension-navigation",
"task": "ci:e2e-browser-extension"
diff --git a/.github/scripts/ci-plan.gql b/.github/scripts/ci-plan.gql
index 78dcbf3eaab..5f44d548823 100644
--- a/.github/scripts/ci-plan.gql
+++ b/.github/scripts/ci-plan.gql
@@ -3,7 +3,7 @@ query($base: String!, $head: String!) {
affectedTasks(base: $base, head: $head, tasks: [
"test", "build", "type-check", "ci:integration", "ci:desktop-live",
"ci:e2e-scim", "ci:e2e-cli", "ci:e2e-stop-after", "ci:e2e-desktop-inbox",
- "ci:e2e-assistant-actions", "ci:e2e-organization-search",
+ "ci:e2e-assistant-actions", "ci:e2e-organization-search", "ci:e2e-org-redesign",
"ci:e2e-docs-platform",
"ci:e2e-browser-extension", "ci:e2e-project-files", "ci:e2e-mobile", "ci:e2e-seo"
]) {
diff --git a/.github/scripts/http-e2e.sh b/.github/scripts/http-e2e.sh
index 8c5f185e7cd..f3e43d7cadb 100755
--- a/.github/scripts/http-e2e.sh
+++ b/.github/scripts/http-e2e.sh
@@ -3,7 +3,7 @@
# CI groups, which reuse the production build from `e2e-build`.
# The job extracts it into apps/sim/.next before running these suites.
#
-# Usage: http-e2e.sh (run from apps/sim)
+# Usage: http-e2e.sh (run from apps/sim)
#
# The job provides DATABASE_URL, BETTER_AUTH_SECRET and ENCRYPTION_KEY; each group sets the rest of
# its app's environment here. Reports and server logs land in $RUNNER_TEMP/e2e.
@@ -20,7 +20,7 @@
# telemetry flush runs detached and still writes .next/dev).
set -euo pipefail
-group=${1:?usage: http-e2e.sh }
+group=${1:?usage: http-e2e.sh }
report_dir="$RUNNER_TEMP/e2e"
ready_timeout_seconds=600
mkdir -p "$report_dir"
@@ -32,6 +32,7 @@ status_log=''
realtime_pid=''
realtime_tag=''
realtime_log=''
+agent_pid=''
browsers_pid=''
local_app_url=''
# `dev` or `start`; a group sets it before start_app.
@@ -63,6 +64,10 @@ finish() {
tail -n 100 "$realtime_log"
fi
fi
+ if [ -n "$agent_pid" ]; then
+ kill "$agent_pid" 2>/dev/null || true
+ wait "$agent_pid" 2>/dev/null || true
+ fi
if [ -n "$browsers_pid" ]; then
kill -- "-$browsers_pid" 2>/dev/null || true
wait "$browsers_pid" 2>/dev/null || true
@@ -133,6 +138,13 @@ start_realtime() {
wait_ready "$realtime_pid" "http://127.0.0.1:$port/health" "$name realtime server"
}
+# stop_app: stops the app start_app started, so a group can start it again under other flags.
+stop_app() {
+ bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" "$app_tag"
+ wait "$server_pid" 2>/dev/null || true
+ server_pid=''
+}
+
# wait_ready
wait_ready() {
local pid=$1 url=$2 label=$3
@@ -268,6 +280,59 @@ case "$group" in
esac
;;
+ # One hosted Enterprise organization, a scripted worker in place of the
+ # model, and the app twice: with People off, then on. Hosted is decided by the
+ # app's hostname, which the job maps to loopback. Both phases report, whichever fails.
+ org-redesign)
+ app_server=start
+ export REDIS_URL=redis://127.0.0.1:6379
+ export E2E_APP_PUBLIC_URL=http://org-redesign.sim.ai:3029
+ export NEXT_PUBLIC_APP_URL="iframe.php?url=https%3A%2F%2Fgithub.com%2F%24E2E_APP_PUBLIC_URL"
+ export INTERNAL_API_BASE_URL=http://127.0.0.1:3029 TRUSTED_ORIGINS=http://127.0.0.1:3029
+ export NEXT_PUBLIC_SOCKET_URL=http://127.0.0.1:3030 SOCKET_SERVER_URL=http://127.0.0.1:3030
+ export BILLING_ENABLED=true NEXT_PUBLIC_BILLING_ENABLED=true
+ export ENTERPRISE_ENABLED=true NEXT_PUBLIC_ENTERPRISE_ENABLED=true
+ export ORGANIZATIONS_ENABLED=true NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true
+ export SSO_ENABLED=true NEXT_PUBLIC_SSO_ENABLED=true
+ export KNOWLEDGE_MEMBER_ACCESS=true CREDENTIAL_GROUPS=true ACCESS_CONTROL_ENABLED=true
+ export NEXT_PUBLIC_ACCESS_CONTROL_ENABLED=true SIM_LINKS=true
+ export COPILOT_TOOL_PERMISSIONS_ENABLED=true
+ export INTERNAL_API_SECRET=org-redesign-ci-local-secret-at-least-32-characters
+ export COPILOT_API_KEY=org-redesign-ci-local-copilot-key MOTHERSHIP_SIM_TRANSPORT=direct
+ export SIM_AGENT_API_URL=http://127.0.0.1:3031
+ export GOOGLE_CLIENT_ID=org-redesign-google-client GOOGLE_CLIENT_SECRET=org-redesign-google-secret
+ export JIRA_CLIENT_ID=org-redesign-jira-client JIRA_CLIENT_SECRET=org-redesign-jira-secret
+ export CONFLUENCE_CLIENT_ID=org-redesign-confluence-client CONFLUENCE_CLIENT_SECRET=org-redesign-confluence-secret
+ export STRIPE_SECRET_KEY=sk_test_org_redesign STRIPE_WEBHOOK_SECRET=whsec_org_redesign
+ export DB_TX_TRIPWIRE=throw
+ export BROWSER_EXTENSION_IDS=abcdefghijklmnopabcdefghijklmnop
+ bun --no-env-file scripts/org-redesign-seed.ts
+ start_browser_install org-redesign --only-shell chromium
+ bun --no-env-file scripts/org-redesign-e2e/scripted-agent.ts 3031 \
+ > "$report_dir/org-redesign-agent.log" 2>&1 &
+ agent_pid=$!
+ wait_ready "$agent_pid" http://127.0.0.1:3031/health 'scripted agent'
+ start_app org-redesign 3029 'organization redesign' record-http-status
+ start_realtime org-redesign 3030
+ wait_for_browsers
+ run_status=0
+ ORG_REDESIGN_E2E_ORIGIN="$E2E_APP_PUBLIC_URL" \
+ ORG_REDESIGN_E2E_DATABASE_URL="iframe.php?url=https%3A%2F%2Fgithub.com%2F%24DATABASE_URL" \
+ ORG_REDESIGN_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
+ ORG_REDESIGN_E2E_REPORT_PATH="$report_dir/org-redesign-ui-report.json" \
+ bun --no-env-file scripts/test-org-redesign-e2e.ts || run_status=$?
+ stop_app
+ export PEOPLE=true
+ start_app org-redesign-people 3029 'organization redesign, People on'
+ ORG_REDESIGN_E2E_PHASE=people \
+ ORG_REDESIGN_E2E_ORIGIN="$E2E_APP_PUBLIC_URL" \
+ ORG_REDESIGN_E2E_DATABASE_URL="iframe.php?url=https%3A%2F%2Fgithub.com%2F%24DATABASE_URL" \
+ ORG_REDESIGN_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
+ ORG_REDESIGN_E2E_REPORT_PATH="$report_dir/org-redesign-people-ui-report.json" \
+ bun --no-env-file scripts/test-org-redesign-e2e.ts || run_status=$?
+ exit "$run_status"
+ ;;
+
browser-extension|browser-extension-native|browser-extension-navigation|browser-extension-composer|browser-extension-access)
extension_group=all
case "$group" in
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index e0d1d77f3b8..382396915fe 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -207,7 +207,7 @@ jobs:
needs: [changes, e2e-build]
if: needs.changes.outputs.http == 'true'
runs-on: ubuntu-24.04
- timeout-minutes: 20
+ timeout-minutes: ${{ matrix.group == 'org-redesign' && 45 || 20 }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.changes.outputs.http_matrix) }}
@@ -227,7 +227,7 @@ jobs:
--health-retries 10
# Desktop inbox, browser extension, and Project Files use Redis.
redis:
- image: ${{ (matrix.group == 'desktop-inbox' || matrix.group == 'assistant-actions' || matrix.group == 'organization-search' || startsWith(matrix.group, 'browser-extension-') || matrix.group == 'project-files') && 'redis:8.2-alpine' || '' }}
+ image: ${{ (matrix.group == 'desktop-inbox' || matrix.group == 'assistant-actions' || matrix.group == 'organization-search' || matrix.group == 'org-redesign' || startsWith(matrix.group, 'browser-extension-') || matrix.group == 'project-files') && 'redis:8.2-alpine' || '' }}
ports:
- 6379:6379
options: >-
@@ -293,6 +293,10 @@ jobs:
if: matrix.group == 'scim'
run: printf '127.0.0.1 scim-e2e.sim.ai\n' | sudo tee -a /etc/hosts
+ - name: Map the hosted organization fixture to loopback
+ if: matrix.group == 'org-redesign'
+ run: printf '127.0.0.1 org-redesign.sim.ai\n' | sudo tee -a /etc/hosts
+
# No step timeout: the job's bound covers a hang without cutting a slow but healthy suite
# short of writing its report.
- name: Run end-to-end suites
diff --git a/AGENTS.md b/AGENTS.md
index f866dc555d9..c0893c0170d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,7 +5,7 @@ This file (also `AGENTS.md`) holds the repo-wide rules. Area detail lives in `.c
## Global Standards
- **Package manager**: `bun` and `bunx`, never `npm` and `npx`.
-- **Logging**: `createLogger` from `@sim/logger`; `logger.info` / `logger.warn` / `logger.error`, never `console.log`. Inside `withRouteHandler` the logger already carries the request ID — no manual `withMetadata({ requestId })`.
+- **Logging**: `createLogger` from `@sim/logger`; `logger.info` / `logger.warn` / `logger.error`, never `console.log`. Messages are static text; ids go in metadata (`logger.info('Sync started', { requestId, documentId })`), never a `[${requestId}]` tag or an interpolated request id, so lines group and filter by field (`check:source-conventions` rule `log-message-tag`). Inside `withRouteHandler` every line already carries the request ID, so a `generateRequestId()` id there needs no field and no manual `withMetadata({ requestId })`.
- **Comments**: name things so the code explains itself. TSDoc documents exported APIs and non-obvious modules. An inline `//` is only for a terse, non-obvious *why*, or for a script-enforced `// : ` annotation (`boundary-raw-fetch`, `double-cast-allowed`, `boundary-raw-json`, `untyped-response`, `rq-lint-allow`, `client-boundary-allow`, `utils-lint-allow`, …). History belongs in the commit message. No `====` separators, commented-out code, or ESLint directives (`eslint-disable`/`eslint-enable`; nothing runs ESLint; `check:comment-hygiene` enforces all three). The `/you-might-not-need-a-comment` skill applies this to a diff.
- **ID generation**: `generateId()` (UUID v4, the default) or `generateShortId(size?)` (URL-safe, 21 chars by default) from `@sim/utils/id` — never `crypto.randomUUID()`, `nanoid`, or `uuid`. Both use `crypto.getRandomValues()`, so they also work in non-secure (HTTP) browsers. For other randomness, `@sim/utils/random` (`randomInt`, `randomFloat`, `randomItem`, `generateRandomBytes`, `generateRandomHex`) — never `Math.random()` or `crypto.randomBytes()`.
- **Common utilities**: use the shared helpers from the `@sim/utils/` subpaths instead of inline implementations (`check:utils` bans most of the inline forms below):
diff --git a/CLAUDE.md b/CLAUDE.md
index 981af4c0214..6162cd1934b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,7 +5,7 @@ This file (also `AGENTS.md`) holds the repo-wide rules. Area detail lives in `.c
## Global Standards
- **Package manager**: `bun` and `bunx`, never `npm` and `npx`.
-- **Logging**: `createLogger` from `@sim/logger`; `logger.info` / `logger.warn` / `logger.error`, never `console.log`. Inside `withRouteHandler` the logger already carries the request ID — no manual `withMetadata({ requestId })`.
+- **Logging**: `createLogger` from `@sim/logger`; `logger.info` / `logger.warn` / `logger.error`, never `console.log`. Messages are static text; ids go in metadata (`logger.info('Sync started', { requestId, documentId })`), never a `[${requestId}]` tag or an interpolated request id, so lines group and filter by field (`check:source-conventions` rule `log-message-tag`). Inside `withRouteHandler` every line already carries the request ID, so a `generateRequestId()` id there needs no field and no manual `withMetadata({ requestId })`.
- **Comments**: name things so the code explains itself. TSDoc documents exported APIs and non-obvious modules. An inline `//` is only for a terse, non-obvious *why*, or for a script-enforced `// : ` annotation (`boundary-raw-fetch`, `double-cast-allowed`, `boundary-raw-json`, `untyped-response`, `rq-lint-allow`, `client-boundary-allow`, `utils-lint-allow`, …). History belongs in the commit message. No `====` separators, commented-out code, or ESLint directives (`eslint-disable`/`eslint-enable`; nothing runs ESLint; `check:comment-hygiene` enforces all three). The `/you-might-not-need-a-comment` skill applies this to a diff.
- **ID generation**: `generateId()` (UUID v4, the default) or `generateShortId(size?)` (URL-safe, 21 chars by default) from `@sim/utils/id` — never `crypto.randomUUID()`, `nanoid`, or `uuid`. Both use `crypto.getRandomValues()`, so they also work in non-secure (HTTP) browsers. For other randomness, `@sim/utils/random` (`randomInt`, `randomFloat`, `randomItem`, `generateRandomBytes`, `generateRandomHex`) — never `Math.random()` or `crypto.randomBytes()`.
- **Common utilities**: use the shared helpers from the `@sim/utils/` subpaths instead of inline implementations (`check:utils` bans most of the inline forms below):
diff --git a/README.md b/README.md
index 4ca3af46cd4..5bdd7432fcc 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
-
+
diff --git a/apps/desktop/src/main/app-routes.test.ts b/apps/desktop/src/main/app-routes.test.ts
new file mode 100644
index 00000000000..f7218cd2af6
--- /dev/null
+++ b/apps/desktop/src/main/app-routes.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+
+import { settingsRoute } from '@/main/app-routes'
+
+describe('settingsRoute', () => {
+ it('opens the settings of the organization the user was last in', () => {
+ expect(settingsRoute('/o/org-1/search?q=x')).toBe('/o/org-1/settings/general')
+ })
+
+ it('keeps the desktop settings of the workspace the user was last in', () => {
+ expect(settingsRoute('/workspace/ws-1/home')).toBe('/workspace/ws-1/settings/desktop')
+ })
+
+ it.each([
+ ['the workspace picker', '/workspace'],
+ ['the app entry', '/home'],
+ ['no saved route', undefined],
+ ['a route that only starts like an organization', '/oauth/o/org-1'],
+ ['an unsafe route', '//evil.example/o/org-1'],
+ ])('resolves settings through the web app from %s', (_label, lastRoute) => {
+ expect(settingsRoute(lastRoute)).toBe('/workspace?redirect=settings')
+ })
+})
diff --git a/apps/desktop/src/main/app-routes.ts b/apps/desktop/src/main/app-routes.ts
index abbf62bba33..22d0ad9f9e5 100644
--- a/apps/desktop/src/main/app-routes.ts
+++ b/apps/desktop/src/main/app-routes.ts
@@ -16,6 +16,12 @@ import { isSafeInternalPath } from '@/main/config'
*/
export const APP_ENTRY_ROUTE = '/home'
+/**
+ * Settings for a viewer whose last route names no workspace or organization. The web
+ * app resolves it to their most recent workspace, or to their organization's settings.
+ */
+const SETTINGS_ENTRY_ROUTE = '/workspace?redirect=settings'
+
/** Workspace id from the last visited route, or null when it carries none. */
function workspaceIdFromRoute(lastRoute: string | undefined): string | null {
if (isSafeInternalPath(lastRoute)) {
@@ -27,6 +33,16 @@ function workspaceIdFromRoute(lastRoute: string | undefined): string | null {
return null
}
+function organizationIdFromRoute(lastRoute: string | undefined): string | null {
+ if (isSafeInternalPath(lastRoute)) {
+ const match = /^\/o\/([^/?#]+)/.exec(lastRoute)
+ if (match) {
+ return match[1]
+ }
+ }
+ return null
+}
+
/**
* Route for "New Chat": the home (chat) surface of the workspace the user was
* last in, falling back to the app entry when the last route carries no
@@ -38,10 +54,14 @@ export function newChatRoute(lastRoute: string | undefined): string {
}
/**
- * Route for "Settings…": the Sim app's settings surface for the workspace the
- * user was last in, falling back to the app entry.
+ * Route for "Settings…": the settings of the workspace or organization the user
+ * was last in. Never the app entry, which lands on a page without settings.
*/
export function settingsRoute(lastRoute: string | undefined): string {
const workspaceId = workspaceIdFromRoute(lastRoute)
- return workspaceId ? `/workspace/${workspaceId}/settings/desktop` : APP_ENTRY_ROUTE
+ if (workspaceId) {
+ return `/workspace/${workspaceId}/settings/desktop`
+ }
+ const organizationId = organizationIdFromRoute(lastRoute)
+ return organizationId ? `/o/${organizationId}/settings/general` : SETTINGS_ENTRY_ROUTE
}
diff --git a/apps/desktop/src/main/handoff.test.ts b/apps/desktop/src/main/handoff.test.ts
index b55193ce83b..6307ebb063d 100644
--- a/apps/desktop/src/main/handoff.test.ts
+++ b/apps/desktop/src/main/handoff.test.ts
@@ -1,9 +1,11 @@
import { get as httpGet } from 'node:http'
+import type { BrowserWindow } from 'electron'
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => import('@/test/electron-mock'))
import {
+ createAuthFlow,
createHandoffManager,
type HandoffCallback,
type HandoffCallbacks,
@@ -161,6 +163,31 @@ describe('createHandoffManager', () => {
})
})
+describe('login handoff landing', () => {
+ it('lands on the app entry after redeeming the login token', async () => {
+ const win = {
+ isDestroyed: () => false,
+ loadURL: vi.fn(async () => {}),
+ show: vi.fn(),
+ focus: vi.fn(),
+ webContents: {
+ getURL: () => 'https://sim.ai/login',
+ executeJavaScript: vi.fn(async () => 200),
+ },
+ }
+ const flow = createAuthFlow({
+ handoff: { consume: () => true } as unknown as ReturnType,
+ origin: () => 'https://sim.ai',
+ events: makeEvents(),
+ ensureMainWindow: async () => win as unknown as BrowserWindow,
+ })
+
+ await flow.handleCallback({ token: VALID_TOKEN, state: _VALID_STATE })
+
+ expect(win.loadURL).toHaveBeenCalledExactlyOnceWith('https://sim.ai/home')
+ })
+})
+
describe('connect handoff account pinning', () => {
it('keeps a source request correlated across the browser and native completion', async () => {
const deps = makeDeps()
diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts
index caeb3a4b612..40fd7e3586c 100644
--- a/apps/desktop/src/main/handoff.ts
+++ b/apps/desktop/src/main/handoff.ts
@@ -6,6 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import type { BrowserWindow } from 'electron'
import { app } from 'electron'
+import { APP_ENTRY_ROUTE } from '@/main/app-routes'
import { showShellDialog } from '@/main/dialogs'
import type { EventRecorder } from '@/main/observability'
@@ -451,7 +452,7 @@ export interface AuthFlow {
/**
* Orchestrates the login handoff: opening the system browser, consuming the
- * loopback callback, redeeming the token, and navigating to the workspace. A
+ * loopback callback, redeeming the token, and navigating to the app entry. A
* failed or expired callback never leaves a partial session — the window lands
* back on /login.
*/
@@ -521,7 +522,7 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow {
}
deps.events.record('handoff_redeem_ok')
try {
- await win.loadURL(`${origin}/workspace`)
+ await win.loadURL(`${origin}${APP_ENTRY_ROUTE}`)
} catch {}
win.show()
win.focus()
diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts
index b5002f4b69c..da5de010937 100644
--- a/apps/docs/components/ui/icon-mapping.ts
+++ b/apps/docs/components/ui/icon-mapping.ts
@@ -426,6 +426,7 @@ export const blockTypeToIconMap: Record = {
fireflies_v2: FirefliesIcon,
fivetran: FivetranIcon,
flint: FlintIcon,
+ flow: ApiIcon,
function: CodeIcon,
gamma: GammaIcon,
generic: WebhookIcon,
@@ -490,6 +491,7 @@ export const blockTypeToIconMap: Record = {
intercom: IntercomIcon,
intercom_v2: IntercomIcon,
intune: IntuneIcon,
+ jama: ApiIcon,
jina: JinaAIIcon,
jira: JiraIcon,
jira_service_management: JiraServiceManagementIcon,
diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx
index 4cd441379c5..f0f98056185 100644
--- a/apps/docs/content/docs/cli/credentials.mdx
+++ b/apps/docs/content/docs/cli/credentials.mdx
@@ -168,6 +168,7 @@ sim credentials update [options]
| `--service-account-json ` | No | Write-only provider service-account JSON configuration, including Oracle Database connection fields. |
| `--api-token ` | No | Write-only provider API token. |
| `--domain ` | No | Provider account domain. |
+| `--provider-workspace-id ` | No | Provider workspace identifier, separate from the Sim workspace. |
| `--atlassian-product ` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. |
| `--signing-secret ` | No | Write-only webhook signing secret. |
| `--bot-token ` | No | Write-only bot token. |
diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx
index 6d9396c5ba1..d14b600e979 100644
--- a/apps/docs/content/docs/cli/reference.mdx
+++ b/apps/docs/content/docs/cli/reference.mdx
@@ -718,6 +718,7 @@ sim credentials update [options]
| `--service-account-json ` | No | Write-only provider service-account JSON configuration, including Oracle Database connection fields. |
| `--api-token ` | No | Write-only provider API token. |
| `--domain ` | No | Provider account domain. |
+| `--provider-workspace-id ` | No | Provider workspace identifier, separate from the Sim workspace. |
| `--atlassian-product ` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. |
| `--signing-secret ` | No | Write-only webhook signing secret. |
| `--bot-token ` | No | Write-only bot token. |
@@ -5102,7 +5103,7 @@ sim selectors get [options]
| Option | Required | Description |
| --- | --- | --- |
-| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
+| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. Defaults to {}. (JSON, or @path / @- to read a file or stdin). |
| `--id ` | Yes | Provider option ID to resolve with the same selector and declared context. |
@@ -5124,7 +5125,7 @@ sim selectors list [options]
| Option | Required | Description |
| --- | --- | --- |
-| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
+| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. Defaults to {}. (JSON, or @path / @- to read a file or stdin). |
| `--search ` | No | Provider option search text. |
| `--cursor ` | No | Continue from nextCursor returned by a previous result. |
diff --git a/apps/docs/content/docs/cli/selectors.mdx b/apps/docs/content/docs/cli/selectors.mdx
index 39e82af527b..544e39d50be 100644
--- a/apps/docs/content/docs/cli/selectors.mdx
+++ b/apps/docs/content/docs/cli/selectors.mdx
@@ -25,7 +25,7 @@ sim selectors list [options]
| Option | Required | Description |
| --- | --- | --- |
-| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
+| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. Defaults to {}. (JSON, or @path / @- to read a file or stdin). |
| `--search ` | No | Provider option search text. |
| `--cursor ` | No | Continue from nextCursor returned by a previous result. |
@@ -49,7 +49,7 @@ sim selectors get [options]
| Option | Required | Description |
| --- | --- | --- |
-| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
+| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. Defaults to {}. (JSON, or @path / @- to read a file or stdin). |
| `--id ` | Yes | Provider option ID to resolve with the same selector and declared context. |
diff --git a/apps/docs/content/docs/integrations/firecrawl.mdx b/apps/docs/content/docs/integrations/firecrawl.mdx
index 6ad51a2ede1..cbd6dd32e40 100644
--- a/apps/docs/content/docs/integrations/firecrawl.mdx
+++ b/apps/docs/content/docs/integrations/firecrawl.mdx
@@ -454,4 +454,38 @@ Retrieve the remaining and allocated Firecrawl credits for the team.
+### Firecrawl Enrich
+
+Find work emails, phone numbers, LinkedIn profiles, company domains, and company information through Firecrawl Alexandria. Uses FullEnrich for contact lookups and People Data Labs for company, name-based profile, and single-part-name contact lookups. Provider terms must be enabled in Firecrawl. People Data Labs has a 50,000-record annual team limit.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Firecrawl API key |
+| `enrichmentType` | string | Yes | Lookup type: work_email, phone, linkedin, company, or company_domain |
+| `fullName` | string | No | Full name of the person |
+| `companyDomain` | string | No | Company website domain |
+| `companyName` | string | No | Company name when the domain is unknown |
+| `linkedinUrl` | string | No | LinkedIn profile URL for work email or mobile lookup |
+| `email` | string | No | Email address for reverse LinkedIn profile lookup |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `email` | string | Work email, when found |
+| `emailVerified` | boolean | True only when FullEnrich reports DELIVERABLE; catch-all and high-probability addresses remain unverified |
+| `phone` | string | Phone number, preferring mobile when available |
+| `linkedinUrl` | string | LinkedIn profile URL, when found |
+| `domain` | string | Company website domain, when found |
+| `employeeCount` | string | Company headcount or size range |
+| `description` | string | Company description |
+| `creditsUsed` | number | Firecrawl credits charged for this lookup |
+| `statusCode` | number | Provider or HTTP response status code |
+
+
+
diff --git a/apps/docs/content/docs/integrations/flow.mdx b/apps/docs/content/docs/integrations/flow.mdx
new file mode 100644
index 00000000000..9fef14d810c
--- /dev/null
+++ b/apps/docs/content/docs/integrations/flow.mdx
@@ -0,0 +1,567 @@
+---
+title: Flow Engineering
+description: Manage engineering requirements and traceability
+integration:
+ type: "flow"
+ color: "#2448C7"
+---
+
+import { ReferenceDetails } from "@/components/ui/reference-details"
+
+
+{/* MANUAL-CONTENT-START:intro */}
+[Flow Engineering](https://www.flowengineering.com/) connects requirements, systems, tests, and their relationships. Use the Flow block to search current project data, read values and history, manage discussions, and propose changes on a working branch.
+
+## Connect an account
+
+In Flow, open **Settings → API & MCP**, generate a personal API key, and copy it. Create a connection from the block's **Flow Account** field with:
+
+- **API key:** The complete key, including its `brd_` prefix.
+- **API URL:** The URL from Flow's API settings. The default is `https://backend.branch.flowengineering.com`. The `/customer-api` suffix opens the API documentation and is removed from requests.
+- **Workspace identifier:** The final segment of the MCP URL. For `https://backend.branch.flowengineering.com/mcp/your-workspace`, enter `your-workspace`.
+
+The key uses its owner's current Flow permissions. It does not grant additional project access or bypass read-only roles. Revoke or replace it in Flow when needed. This integration uses the current customer API, not the older refresh-token API under `api.flowengineering.com/rest/v1`.
+
+## Work with project data
+
+Select a project, then use **Get Data Model** to discover its categories and field keys. Requirements and tests are entities in those categories; names and custom fields can vary by project. Use the returned IDs rather than display names.
+
+**Search Entities** returns up to 100 compact matches by name, field value, or exact Flow/Human ID. Use **Get Entities** to read their full values and relationships. Search reflects the selected branch and may have incomplete text results while Flow's index warms.
+
+**List Entities** reads one page. Pass `nextPageStart` as **Cursor** and continue while `moreAvailable` is true, including after an empty page. **Filter Entities** and **Get Entity History** instead return `nextCursor`; continue until it is null. No operation automatically downloads every page.
+
+## Make changes
+
+Omitting **Branch** reads or writes Base (`master`). Use **Create Branch** and select its returned ID to prepare changes separately. Flow's project permissions and branch protections still apply; the integration does not request the admin bypass option.
+
+**Create Entities** and **Set Values** accept up to 100 entries per request. Each batch is atomic, but separate requests are separate transactions. Creates and other mutations are not automatically retried. After a timeout, read back the affected data before repeating a write.
+
+To reply to a discussion, pass the original comment ID as **Entity ID**, even when replying to a later reply. Use the same branch for the original thread and its replies. Resolve a thread by updating the original comment.
+
+See [Flow's API conventions](https://docs.flowengineering.com/api/conventions) for branch and pagination behavior, and [its API reference](https://docs.flowengineering.com/api/reference) for supported field values and filters.
+{/* MANUAL-CONTENT-END */}
+
+## Use the block [#usage-instructions]
+
+Read and update Flow Engineering projects, requirements, tests, and other entities through its customer API. Search current project data, inspect categories and relationships, manage comments, and make controlled changes on a working branch using your connected account permissions.
+
+## Actions
+
+### Flow List Projects
+
+List projects accessible to the connected Flow user. The API returns up to the requested limit without a continuation cursor.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `limit` | number | No | Maximum results in this page |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `projects` | array | List Projects result |
+
+
+
+### Flow Get Project
+
+Read a Flow project by its UUID.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `project` | json | Get Project result |
+
+
+
+### Flow Get Data Model
+
+Read the project categories and field definitions before interpreting values or writing custom fields.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `categories` | array | Get Data Model result |
+
+
+
+### Flow List Branches
+
+List the Flow project branches and their review and protection state.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `branches` | array | List Branches result |
+
+
+
+### Flow Create Branch
+
+Create a working branch before modifying requirements, tests, or other project entities.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `branchName` | string | No | Name of the new branch |
+| `parentId` | string | No | Parent branch ID; omit for Base |
+| `description` | string | No | Description of the new branch |
+| `isVariant` | boolean | No | Create a variant branch |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Created branch ID |
+| `number` | number | Assigned branch number |
+
+
+
+### Flow Search Entities
+
+Search live Flow entities by name, supported field values, or exact Flow/Human ID. Returns at most 100 compact matches; fetch entities for full values. Text results may be unavailable while the provider index warms.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+| `entityType` | string | No | Restrict to this category ID from Get Data Model |
+| `query` | string | Yes | Search names and supported field values, or an exact Flow/Human ID; at most 1024 characters |
+| `limit` | number | No | Maximum results in this page |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `matches` | array | Search Entities result |
+
+
+
+### Flow List Entities
+
+Read one page of live Flow entities. Continue with nextPageStart while moreAvailable is true, including after an empty page.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+| `entityType` | string | No | Restrict to this category ID from Get Data Model |
+| `cursor` | string | No | Opaque continuation cursor from the previous page |
+| `limit` | number | No | Maximum results in this page |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `entities` | array | Entity states on this page |
+| `nextPageStart` | string | Opaque cursor for the next page |
+| `moreAvailable` | boolean | Continue even after empty pages while true |
+
+
+
+### Flow Get Entities
+
+Read the full values, relationships, and timestamps of up to 100 Flow entities in one project and branch.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+| `entityIds` | string | Yes | Comma-separated entity IDs; up to 100 per read |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `entities` | array | Get Entities result |
+
+
+
+### Flow Create Entity
+
+Create an entity in a category on the selected branch. Branch protection and the connected user permissions remain enforced.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+| `entityType` | string | Yes | Category ID returned by Get Data Model |
+| `name` | string | Yes | Entity name |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `entity` | json | Create Entity result |
+
+
+
+### Flow Create Entities
+
+Create 1–100 entities atomically on a single branch. A timed-out request may already have committed; inspect the branch before retrying.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+| `entities` | json | Yes | Array of 1–100 entities: \[\{entityType, name, id?\}\]. IDs may be omitted for server generation |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `entities` | array | Create Entities result |
+
+
+
+### Flow Set Value
+
+Set one field on a Flow entity using a key from the data model and a JSON value of the documented field type.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `entityId` | string | Yes | Entity ID returned by Search Entities or List Entities |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+| `fieldKey` | string | Yes | Stored field key returned by Get Data Model |
+| `value` | string | Yes | JSON-encoded field value matching the data model. Use "text" for a string, 123 for a number, true or false for a boolean, an array or object, or null to clear the field. Use "" for an empty string. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `entity` | json | Set Value result |
+
+
+
+### Flow Set Values
+
+Set 1–100 field values atomically on one branch. Returns each affected entity once in first-touched order.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+| `updates` | json | Yes | Array of 1–100 field updates: \[\{entityId, key, value\}\] |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `entities` | array | Set Values result |
+
+
+
+### Flow Delete Entity
+
+Delete an entity on the selected branch. Branch protection is enforced.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `entityId` | string | Yes | Entity ID returned by Search Entities or List Entities |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+
+#### Output
+
+No output fields.
+
+
+
+### Flow Restore Entity
+
+Restore a deleted entity with its prior fields and links. Restoring a child does not restore its deleted parent.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `entityId` | string | Yes | Entity ID returned by Search Entities or List Entities |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+
+#### Output
+
+No output fields.
+
+
+
+### Flow Add Link
+
+Add a downstream relationship between Flow entities on the selected branch.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `entityId` | string | Yes | Entity ID returned by Search Entities or List Entities |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+| `destinationId` | string | Yes | ID of the downstream entity to link or unlink |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `entity` | json | Add Link result |
+
+
+
+### Flow Remove Link
+
+Remove a downstream relationship between Flow entities on the selected branch.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `entityId` | string | Yes | Entity ID returned by Search Entities or List Entities |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+| `destinationId` | string | Yes | ID of the downstream entity to link or unlink |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `entity` | json | Remove Link result |
+
+
+
+### Flow List Comments
+
+Read direct comment threads for an entity or direct replies to the original comment ID. Keep the same branch when following replies.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `entityId` | string | Yes | Entity ID, or the original comment ID when reading or posting thread replies |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+| `includeResolved` | boolean | No | Include resolved comments |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `comments` | array | List Comments result |
+
+
+
+### Flow Get Comment
+
+Read a comment using its ID and its returned entity_id as the parent path.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `entityId` | string | Yes | Entity ID, or the original comment ID when reading or posting thread replies |
+| `commentId` | string | Yes | Comment ID; entityId must be the comment's returned entity_id |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `comment` | json | Get Comment result |
+
+
+
+### Flow Create Comment
+
+Start a thread on an entity or reply using the original comment ID as entityId. Set the branch explicitly; replies do not inherit it.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `entityId` | string | Yes | Entity ID, or the original comment ID when reading or posting thread replies |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+| `text` | string | Yes | Plain-text comment body |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `comment` | json | Create Comment result |
+
+
+
+### Flow Update Comment
+
+Edit comment text or resolve/reopen its thread. Use the original comment to change the thread status.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `entityId` | string | Yes | Entity ID, or the original comment ID when reading or posting thread replies |
+| `commentId` | string | Yes | Comment ID; entityId must be the comment's returned entity_id |
+| `text` | string | No | Replacement plain-text comment body |
+| `resolved` | boolean | No | Resolve the thread with true or reopen it with false |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `comment` | json | Update Comment result |
+
+
+
+### Flow Delete Comment
+
+Delete a comment authored by the connected user.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `entityId` | string | Yes | Entity ID, or the original comment ID when reading or posting thread replies |
+| `commentId` | string | Yes | Comment ID; entityId must be the comment's returned entity_id |
+
+#### Output
+
+No output fields.
+
+
+
+### Flow Filter Entities
+
+Read one page of IDs matching typed field conditions and AND/OR groups. Continue until nextCursor is null, including after empty pages; results reflect current state.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+| `filter` | json | Yes | Filter object: \{kind:"type",operation:"overlaps",values:\[categoryId\]\}, \{kind:"field",category_id,field_key,operation:"contains",value\}, or \{kind:"group",operator:"and"\|"or",conditions:\[...\]\}. Use data-model IDs and field-compatible operators; at most 50 nodes and 5 group levels |
+| `cursor` | string | No | Opaque continuation cursor from the previous page |
+| `limit` | number | No | Maximum results in this page |
+| `timezone` | string | No | IANA timezone for date filters; defaults to UTC |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `entityIds` | array | Matching entity IDs; fetch entities to read values |
+| `nextCursor` | string | Continue until null, even after an empty page |
+
+
+
+### Flow Get Entity History
+
+Read a page of change history for up to 500 entities. Keep project, branch, and entity IDs fixed while paging; restart if a rebase expires the cursor.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Project UUID returned by List Projects |
+| `branchId` | string | No | Branch ID; omit or use master for Base |
+| `entityIds` | string | Yes | Comma-separated entity IDs; up to 500 per history page |
+| `cursor` | string | No | Opaque continuation cursor from the previous page |
+| `limit` | number | No | Maximum results in this page |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `entries` | array | Entity change history page |
+| `nextCursor` | string | Continue until null |
+
+
+
+
diff --git a/apps/docs/content/docs/integrations/jama.mdx b/apps/docs/content/docs/integrations/jama.mdx
new file mode 100644
index 00000000000..21cc6f8d86b
--- /dev/null
+++ b/apps/docs/content/docs/integrations/jama.mdx
@@ -0,0 +1,901 @@
+---
+title: Jama Connect
+description: Manage requirements, traceability, and verification in Jama Connect
+integration:
+ type: "jama"
+ color: "#E25100"
+---
+
+import { ReferenceDetails } from "@/components/ui/reference-details"
+
+
+{/* MANUAL-CONTENT-START:intro */}
+[Jama Connect](https://www.jamasoftware.com/) manages requirements, their relationships, and verification records. Use the Jama Connect block to search and edit items, inspect relationships and comments, compare versions and baselines, and work with test execution results.
+
+## Connect your account
+
+In Jama Connect, open your profile and select **Set API credentials**. Create a client ID and secret, then save them with your Jama instance URL in the block's **Jama Account** field. Use the instance's base URL, such as `https://example.jamacloud.com`, including a context path if your self-hosted instance uses one.
+
+Jama uses the OAuth client credentials grant for these personal API credentials. Sim obtains short-lived access tokens when needed. The credentials retain their owner's Jama permissions; they do not grant administrator access. Jama requires a **Named Creator** license and REST API access. This authentication method supports Jama Cloud and self-hosted Jama Connect 8.62 or later, including instances that use SSO. See [Jama's API authentication guide](https://dev.jamasoftware.com/api/#advanced-authentication).
+
+## Work with requirements and tests
+
+- **Custom fields:** Use **Get Item Type** to read the internal field names, types, and required fields before creating or updating an item. Use **List Pick List Options** for valid option IDs. Field names and status values vary by instance.
+- **Create items:** Select a project and item type, then supply **Fields** as a JSON object. **Parent Item ID** places the new item inside an existing container. Sets and folders also require **Child Item Type ID**.
+- **Update items:** Supply a JSON Patch array, for example `[{"op":"replace","path":"/fields/name","value":"Updated requirement"}]`. Only the listed paths change. The supported operations are `add`, `remove`, and `replace`.
+- **Search:** **Search Items** uses Jama's abstract item search, which can return requirements, test plans, test cycles, test runs, and attachments. Results include their item type and current fields. File downloads are not included.
+- **Filters:** For a saved filter with `projectScope` set to `CURRENT`, provide **Project** when listing its results.
+- **Test execution:** Read the test run and its item type before updating execution fields. Use Jama's configured field names and status values, and supply ISO 8601 timestamps for execution dates.
+- **Pagination:** List and search operations return one page, with a default of 20 and a maximum of 50 results. Set the next **Start Offset** to `pageInfo.startIndex + pageInfo.resultCount` and stop when it reaches `pageInfo.totalResults`, or when the page is empty.
+
+Requests use the supported `/rest/v1` API. Read requests and idempotent updates retry bounded transient failures; creates and JSON Patch updates are not automatically replayed. Jama Cloud and self-hosted instances can apply their own API throttling. See [Jama's API cookbook](https://dev.jamasoftware.com/cookbook/).
+{/* MANUAL-CONTENT-END */}
+
+## Use the block [#usage-instructions]
+
+Search and manage requirements, inspect custom item types and pick lists, trace upstream and downstream relationships, collaborate with comments, compare item versions and baselines, and read test plans, cycles, and runs or update execution results. Connect with saved Jama API client credentials that retain the account owner’s permissions.
+
+## Actions
+
+### Jama List Projects
+
+List Projects in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `projects` | array | List Projects result |
+
+
+
+### Jama Get Project
+
+Get Project in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | project ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `project` | object | Get Project result |
+
+
+
+### Jama List Item Types
+
+List Item Types in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `projectId` | string | No | Only item types enabled in this project |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `itemTypes` | array | List Item Types result |
+
+
+
+### Jama Get Item Type
+
+Get Item Type in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `itemTypeId` | string | Yes | itemType ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `itemType` | object | Get Item Type result |
+
+
+
+### Jama List Items
+
+List Items in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `projectId` | string | Yes | project ID |
+| `rootOnly` | boolean | No | Only root-level items in the project |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `items` | array | List Items result |
+
+
+
+### Jama Search Items
+
+Search Items in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `query` | string | Yes | Text to find in item names, descriptions, and fields |
+| `projectId` | string | No | Restrict search to a project |
+| `itemTypeId` | string | No | Restrict search to an item type |
+| `documentKey` | string | No | Exact document key |
+| `modifiedSince` | string | No | Only items modified since this ISO 8601 date |
+| `sortBy` | string | No | Sort field and direction, for example modifiedDate.desc |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `items` | array | Search Items result |
+
+
+
+### Jama Get Item
+
+Get Item in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `itemId` | string | Yes | item ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `item` | object | Get Item result |
+
+
+
+### Jama Create Item
+
+Create Item in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | project ID |
+| `itemTypeId` | string | Yes | itemType ID |
+| `fields` | json | Yes | Jama field names and values, including name and required custom fields |
+| `parentItemId` | string | No | Parent item ID; otherwise create at the project root |
+| `childItemTypeId` | string | No | Child item type ID, required for sets and folders |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | number | Created resource ID |
+| `location` | string | API URL of the created resource |
+
+
+
+### Jama Update Item
+
+Update Item in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `itemId` | string | Yes | item ID |
+| `patch` | json | Yes | JSON Patch array using add, remove, or replace, for example \[\{"op":"replace","path":"/fields/name","value":"Updated name"\}\] |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `status` | number | Successful HTTP status code |
+
+
+
+### Jama Delete Item
+
+Delete Item in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `itemId` | string | Yes | item ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `status` | number | Successful HTTP status code |
+
+
+
+### Jama List Item Children
+
+List Item Children in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `itemId` | string | Yes | item ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `items` | array | List Item Children result |
+
+
+
+### Jama List Upstream Items
+
+List Upstream Items in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `itemId` | string | Yes | item ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `items` | array | List Upstream Items result |
+
+
+
+### Jama List Downstream Items
+
+List Downstream Items in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `itemId` | string | Yes | item ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `items` | array | List Downstream Items result |
+
+
+
+### Jama List Relationships
+
+List Relationships in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `projectId` | string | Yes | project ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `relationships` | array | List Relationships result |
+
+
+
+### Jama Get Relationship
+
+Get Relationship in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `relationshipId` | string | Yes | relationship ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `relationship` | object | Get Relationship result |
+
+
+
+### Jama Create Relationship
+
+Create Relationship in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `fromItemId` | string | Yes | fromItem ID |
+| `toItemId` | string | Yes | toItem ID |
+| `relationshipTypeId` | string | No | Relationship type ID; defaults to the instance default |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | number | Created resource ID |
+| `location` | string | API URL of the created resource |
+
+
+
+### Jama Delete Relationship
+
+Delete Relationship in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `relationshipId` | string | Yes | relationship ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `status` | number | Successful HTTP status code |
+
+
+
+### Jama List Relationship Types
+
+List Relationship Types in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `relationshipTypes` | array | List Relationship Types result |
+
+
+
+### Jama List Item Comments
+
+List Item Comments in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `itemId` | string | Yes | item ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `comments` | array | List Item Comments result |
+
+
+
+### Jama Create Comment
+
+Create Comment in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `itemId` | string | Yes | item ID |
+| `text` | string | Yes | Comment text |
+| `inReplyTo` | string | No | Parent comment ID when replying |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | number | Created resource ID |
+| `location` | string | API URL of the created resource |
+
+
+
+### Jama List Item Versions
+
+List Item Versions in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `itemId` | string | Yes | item ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `versions` | array | List Item Versions result |
+
+
+
+### Jama Get Item Version
+
+Get Item Version in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `itemId` | string | Yes | item ID |
+| `versionNumber` | string | Yes | Version number returned by List Item Versions |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `item` | object | Get Item Version result |
+
+
+
+### Jama List Filters
+
+List Filters in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `projectId` | string | No | Only filters for this project |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `filters` | array | List Filters result |
+
+
+
+### Jama Get Filter
+
+Get Filter in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `filterId` | string | Yes | filter ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `filter` | object | Get Filter result |
+
+
+
+### Jama List Filter Results
+
+List Filter Results in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `filterId` | string | Yes | filter ID |
+| `projectId` | string | No | Project ID required for a filter whose projectScope is CURRENT |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `items` | array | List Filter Results result |
+
+
+
+### Jama List Baselines
+
+List Baselines in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `projectId` | string | Yes | project ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `baselines` | array | List Baselines result |
+
+
+
+### Jama Get Baseline
+
+Get Baseline in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `baselineId` | string | Yes | baseline ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `baseline` | object | Get Baseline result |
+
+
+
+### Jama List Baseline Items
+
+List Baseline Items in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `baselineId` | string | Yes | baseline ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `items` | array | List Baseline Items result |
+
+
+
+### Jama List Test Plans
+
+List Test Plans in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `projectId` | string | Yes | project ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `testPlans` | array | List Test Plans result |
+
+
+
+### Jama Get Test Plan
+
+Get Test Plan in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `testPlanId` | string | Yes | testPlan ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `testPlan` | object | Get Test Plan result |
+
+
+
+### Jama List Test Cycles
+
+List Test Cycles in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `testPlanId` | string | Yes | testPlan ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `testCycles` | array | List Test Cycles result |
+
+
+
+### Jama Get Test Cycle
+
+Get Test Cycle in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `testCycleId` | string | Yes | testCycle ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `testCycle` | object | Get Test Cycle result |
+
+
+
+### Jama List Test Runs
+
+List Test Runs in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `testCycleId` | string | Yes | testCycle ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `testRuns` | array | List Test Runs result |
+
+
+
+### Jama Get Test Run
+
+Get Test Run in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `testRunId` | string | Yes | testRun ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `testRun` | object | Get Test Run result |
+
+
+
+### Jama Update Test Run
+
+Update Test Run in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `testRunId` | string | Yes | testRun ID |
+| `fields` | json | Yes | Complete execution fields to update, including testRunStatus, testRunSteps, or executionDate as supported by the item type |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `status` | number | Successful HTTP status code |
+
+
+
+### Jama List Releases
+
+List Releases in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `projectId` | string | Yes | project ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `releases` | array | List Releases result |
+
+
+
+### Jama List Tags
+
+List Tags in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `projectId` | string | Yes | project ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `tags` | array | List Tags result |
+
+
+
+### Jama List Pick Lists
+
+List Pick Lists in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `pickLists` | array | List Pick Lists result |
+
+
+
+### Jama List Pick List Options
+
+List Pick List Options in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startAt` | number | No | Zero-based result offset \(default 0\) |
+| `maxResults` | number | No | Results per page, from 1 to 50 \(default 20\) |
+| `pickListId` | string | Yes | pickList ID |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `options` | array | List Pick List Options result |
+
+
+
+### Jama Get Current User
+
+Get Current User in Jama Connect using the connected user's access permissions.
+
+
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `user` | object | Get Current User result |
+
+
+
+
diff --git a/apps/docs/content/docs/integrations/meta.json b/apps/docs/content/docs/integrations/meta.json
index c2be78df692..ceab5566a24 100644
--- a/apps/docs/content/docs/integrations/meta.json
+++ b/apps/docs/content/docs/integrations/meta.json
@@ -88,6 +88,7 @@
"fireflies",
"fivetran",
"flint",
+ "flow",
"gamma",
"github",
"gitlab",
@@ -135,6 +136,7 @@
"instantly",
"intercom",
"intune",
+ "jama",
"jina",
"jira",
"jira_service_management",
diff --git a/apps/docs/content/docs/introduction/index.mdx b/apps/docs/content/docs/introduction/index.mdx
index b6772bda232..6935e392a95 100644
--- a/apps/docs/content/docs/introduction/index.mdx
+++ b/apps/docs/content/docs/introduction/index.mdx
@@ -55,9 +55,15 @@ For tools outside the catalog, connect an [external MCP server](/agents/mcp) or
The core source is available on [GitHub](https://github.com/simstudioai/sim) under the Apache 2.0 license. Enterprise features use a separate license that requires an active Enterprise subscription for production use. See [self-hosting](/platform/self-hosting) for installation requirements and configuration.
+## Security and compliance
+
+Sim is SOC 2 Type II compliant. Find reports and security details in the [trust center](https://trust.sim.ai/).
+
## Related docs
- [Getting started](/getting-started): Build and run a simple summarizer.
- [Chat](/chat): Build and manage workspace resources conversationally.
- [Workflows](/workflows): Blocks, connections, triggers, and execution.
- [Quick reference](/quick-reference): Look up builder actions.
+- [Pricing](https://www.sim.ai/pricing): Compare Sim Cloud plans.
+- [Comparisons](https://www.sim.ai/comparisons): Sim vs Microsoft Copilot Studio, n8n, Zapier, Make, Dust, and other AI agent platforms.
diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx
index 36c106d58d2..34978bde2ff 100644
--- a/apps/docs/content/docs/search/connect-your-account.mdx
+++ b/apps/docs/content/docs/search/connect-your-account.mdx
@@ -10,19 +10,25 @@ After your admin adds a source in **Settings → Sources**, connect your own acc
## Connect and verify
1. Join the Sim organization through its invitation or SSO. Use the verified email required by the provider connection.
-2. Open **Integrations**, select the app, and choose **Connect** in its header.
-3. Complete authorization in the new tab. If the popup is blocked, allow it and select **Connect** again.
+2. Open **Integrations**, select the app, and choose the app's button under **Connect your account**.
+3. Complete authorization in the new tab. If the popup is blocked, allow it and select the button again.
4. Return to **Search** or **Search → Assistant** and search for content your account can open. Read a result and follow its source link.
-Your organization currently supports one connected account per provider. Open the app to see that account's permissions and connection actions.
+To connect another account of the same app, such as Google Drive for two different work accounts, open the app and choose **Add account**, then pick the other account when the provider asks. You can connect up to five accounts per app, and Search looks across all of them. Open an account to see its tool permissions when the app supports them; each account's menu has **Reconnect** and **Disconnect**.
+
+- Slack accounts must belong to your organization's connected workspace.
+- Zoom, Lucid, and Databricks allow one account, because Sim cannot yet tell their accounts apart.
+- If the provider signs you straight back into a connected account, sign in to the other account with the provider first, then choose **Add account** again.
+
+Reconnecting keeps the same account. To use a different one, add it as a new account or disconnect the old one first. If Sim asks you to reconnect before adding another account, it could not confirm which account an existing connection belongs to. Reconnect that one, then add the other. Your organization and the provider must allow each account to authorize Sim.
The connection is usable when authorization completes; no initial indexing run is required. If a provider is missing, ask an admin to add it in **Sources**. `Not configured` means its app or deployment setup needs an admin's attention.
@@ -33,7 +39,7 @@ You do not choose repositories, channels, labels, or an indexing method during p
| Source setup | Personal connection |
| --- | --- |
| **Member accounts** | Connect your own provider account. |
-| **GitHub App** | Connect GitHub once per Sim organization. Each selected repository must be readable by you and the installation. |
+| **GitHub App** | Connect one or more GitHub accounts. Each selected repository must be readable by you and the installation. |
| Google service account: Gmail, Calendar, Drive | Connect each corresponding Google service. The source verifies your identity and resource access through delegation. |
| Confluence service account | Connect Confluence. Both your account and the service connection must read the selected content. |
| Coda | Connect personal Coda MCP. Service mode also verifies the parent document with its REST token. |
@@ -46,7 +52,7 @@ Creating Jira sprints requires the Jira sprint permission. If your account was c
## Tool permissions
-Open the app in **Integrations** and select your account. Tool permissions appear in its details when the app has supported Assistant tools available to you. Hover over or focus the info button beside a tool to read its description. Set a preference for each operation or use the **Read-only tools** and **Write tools** group controls:
+Open the app in **Integrations**. If you connected several accounts, select one. Tool permissions appear in its details when the app has supported Assistant tools available to you. Hover over or focus the info button beside a tool to read its description. Set a preference for each operation or use the **Read-only tools** and **Write tools** group controls:
| Preference | Behavior |
| --- | --- |
diff --git a/apps/docs/content/docs/search/flow.mdx b/apps/docs/content/docs/search/flow.mdx
new file mode 100644
index 00000000000..6cffb0cd989
--- /dev/null
+++ b/apps/docs/content/docs/search/flow.mdx
@@ -0,0 +1,35 @@
+---
+title: Flow Engineering
+description: Search current engineering items using your own Flow account
+---
+
+Flow Engineering Search retrieves current engineering items when you search. It uses **Member accounts** and respects the permissions of each person's API key. It does not crawl or index the workspace.
+
+## Connect
+
+1. An admin adds **Flow Engineering** in **Settings → Sources**.
+2. In Flow, open **Settings → API & MCP** and copy your personal API key, API host, and workspace identifier.
+3. In Sim **Integrations → Flow Engineering**, enter those values. The provider account email must match your verified Sim email.
+
+The default API host is `backend.branch.flowengineering.com`; use the host shown by your Flow instance. Sim saves the destination with the credential and verifies the current user before each Search operation. Other members cannot search with your connection. Reconnecting preserves the provider identity and workspace; use a new connection for a different account or workspace.
+
+You can connect up to five accounts per provider. Reconnect an existing account to rotate its credentials.
+
+## Search and read
+
+Use a short phrase, optionally scoped to a known Flow project ID. Without a project, Sim searches at most six accessible projects. Each project returns up to 100 matches, subject to the requested result limit. Flow does not provide a continuation cursor for this search endpoint, so narrow the project or query when results are incomplete.
+
+Sim reads the matched items with the same credential and project scope. Results include current text and scalar field values, formulas, and available modification timestamps. Dates and sorting apply to retrieved candidates. Comments, attached files, and structured field values are not included; item text is limited to 120,000 characters with a truncation notice.
+
+Citations currently point to the exact REST item resource, which may require API authentication to open. Search uses the default project branch.
+
+## Requests and permissions
+
+Search uses `GET /project`, `GET /project/{projectId}/entities/search`, and `GET /project/{projectId}/entities/{entity_ids}`. Reads use the project-scoped entity endpoint; identity checks use `GET /users/me`. Requests carry the saved API key and workspace identifier.
+
+Live Search applies timeouts, response-size limits, and a per-call request budget. These are not a fixed requests-per-minute ceiling. Rate limits and permission denials are reported; inaccessible or deleted items are not returned.
+
+## Related docs
+
+- [Flow API authentication](https://docs.flowengineering.com/api/authentication)
+- [Flow API documentation](https://docs.flowengineering.com/api)
diff --git a/apps/docs/content/docs/search/jama.mdx b/apps/docs/content/docs/search/jama.mdx
new file mode 100644
index 00000000000..c36f1c931a0
--- /dev/null
+++ b/apps/docs/content/docs/search/jama.mdx
@@ -0,0 +1,38 @@
+---
+title: Jama Connect
+description: Search current Jama Connect items using your own account
+---
+
+Jama Connect Search retrieves items directly from your instance when you search. It uses **Member accounts**, with each person's own API credentials and current Jama permissions. It does not crawl projects or maintain a content index.
+
+## Connect
+
+1. An admin adds **Jama Connect** in **Settings → Sources**.
+2. In **Integrations → Jama Connect**, connect your own account with your instance URL, client ID, and client secret from your Jama profile.
+3. Use the same email address in Jama and your verified Sim account. Your Jama user must be active and permitted to use the REST API.
+
+Sim exchanges the saved client credentials for a short-lived access token. Searches and reads check the current Jama identity before retrieving content. Shared administrator credentials do not authorize other members' searches. A different instance or provider account requires a new connection.
+
+Self-hosted instance URLs may include a deployment context path. The instance must be reachable from Sim's server environment. If your organization uses dedicated outbound IPs, allowlist those addresses for HTTPS access.
+
+You can connect up to five accounts per provider. Reconnect an existing account to rotate its credentials.
+
+## Search and read
+
+Search with a short phrase or distinctive requirement text. A native query can specify a numeric project ID. Each call retrieves up to 50 items; continue with the returned cursor to request another page.
+
+Results include item keys, names, available text and scalar fields, and modification times. Reads retrieve the current item again. Date filters and newest/oldest sorting apply to the returned candidates, not the entire instance.
+
+Attachments, rich structured fields, and comment discussions are not included. Item text is bounded at 120,000 characters and reports truncation. Citations currently point to the exact REST item resource rather than a Jama application deep link; opening them may require API authentication.
+
+## Requests
+
+Search uses `GET /rest/v1/abstractitems`, reads use `GET /rest/v1/abstractitems/{id}`, and identity checks use `GET /rest/v1/users/current`. Token exchange uses `POST /rest/oauth/token`.
+
+Requests share Sim's live Search timeout, response-size limit, and per-call request budget. These safeguards are not a fixed requests-per-minute limit. Jama access and rate-limit errors are reported without widening permissions or falling back to a shared account.
+
+## Related docs
+
+- [Jama API credentials](https://help.jamasoftware.com/en/get-started/setting-up-your-work-environment/set-api-credentials.html)
+- [Jama REST API](https://rest.jamasoftware.com/)
+- [Abstract item search](https://dev.jamasoftware.com/cookbook/#abstract-items)
diff --git a/apps/docs/content/docs/search/meta.json b/apps/docs/content/docs/search/meta.json
index 7682fb5c4be..445c9e6a3c0 100644
--- a/apps/docs/content/docs/search/meta.json
+++ b/apps/docs/content/docs/search/meta.json
@@ -13,6 +13,7 @@
"coda",
"confluence",
"fireflies",
+ "flow",
"github",
"gitlab",
"gmail",
@@ -21,6 +22,7 @@
"google-meet",
"granola",
"hubspot",
+ "jama",
"jira",
"linear",
"lucid",
diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json
index 9f882a7f233..17f01a09649 100644
--- a/apps/docs/openapi-v2-resources.json
+++ b/apps/docs/openapi-v2-resources.json
@@ -18884,6 +18884,12 @@
"minLength": 1,
"maxLength": 2048
},
+ "providerWorkspaceId": {
+ "description": "Provider workspace identifier, separate from the Sim workspace.",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 256
+ },
"atlassianProduct": {
"description": "Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect.",
"type": "string",
diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json
index 7768b388293..55a938fe415 100644
--- a/apps/docs/openapi-v2-workflows.json
+++ b/apps/docs/openapi-v2-workflows.json
@@ -18832,6 +18832,11 @@
"google.drive",
"google.sheets",
"harmonic.savedSearches",
+ "jama.projects",
+ "jama.itemTypes",
+ "flow.projects",
+ "flow.categories",
+ "flow.branches",
"helpscout.mailboxes",
"helpscout.assignees",
"hubspot.lists",
@@ -19018,6 +19023,11 @@
"google.drive",
"google.sheets",
"harmonic.savedSearches",
+ "jama.projects",
+ "jama.itemTypes",
+ "flow.projects",
+ "flow.categories",
+ "flow.branches",
"helpscout.mailboxes",
"helpscout.assignees",
"hubspot.lists",
diff --git a/apps/sim/AGENTS.md b/apps/sim/AGENTS.md
index 8f3822fa977..f86fa975a71 100644
--- a/apps/sim/AGENTS.md
+++ b/apps/sim/AGENTS.md
@@ -6,7 +6,7 @@ Applies to `apps/sim/**` on top of the root [AGENTS.md](/AGENTS.md), which holds
- React Query hooks and server prefetch: `sim-queries.md`
- `'use client'` server boundary, app/worker runtime env, feature folder layout: `sim-architecture.md`
- Components, hooks, stores, imports: `sim-components.md`, `sim-hooks.md`, `sim-stores.md`, `sim-imports.md`
-- Styling and emcn chrome, UI copy, settings pages: `sim-styling.md`, `sim-ui-copy.md`, `sim-settings-pages.md`
+- Styling and emcn chrome, UI copy, settings pages: `sim-styling.md`, `sim-ui-copy.md`, `sim-settings-pages.md` (workspace), `sim-platform-views.md` (organization views)
- URL state, list and menu order, caching: `sim-url-state.md`, `sim-list-ordering.md`, `sim-caching.md`
- Tools, blocks, triggers: `sim-integrations.md`; isolated-vm sandbox worker: `sim-sandbox.md`
- Anything that returns stored file, run-file, knowledge or table content to Chat: `sim-content-delivery.md`
diff --git a/apps/sim/app/(landing)/pricing/pricing.tsx b/apps/sim/app/(landing)/pricing/pricing.tsx
index 4b34e107313..70fb5851928 100644
--- a/apps/sim/app/(landing)/pricing/pricing.tsx
+++ b/apps/sim/app/(landing)/pricing/pricing.tsx
@@ -1,4 +1,5 @@
import { INTEGRATION_COUNT_LABEL } from '@sim/deployment-config/integration-claims'
+import { CREDITS_PER_DOLLAR } from '@/lib/billing/constants'
import { PricingPlans } from '@/app/(landing)/pricing/components/pricing-plans'
import { PricingStructuredData } from '@/app/(landing)/pricing/components/pricing-structured-data'
@@ -54,6 +55,19 @@ export default function Pricing() {
className='mx-auto flex w-full max-w-[1728px] flex-col gap-7 px-10 pt-8 max-md:px-7 max-lg:px-8 max-xl:px-9'
>
+
+ Sim is open source under the Apache 2.0 license, so you can self-host it for free with
+ Docker Compose or Kubernetes. Enterprise features need a license.{' '}
+
+ Read the self-hosting guide
+
+ . On Sim Cloud, one credit is worth ${1 / CREDITS_PER_DOLLAR}.
+
)
diff --git a/apps/sim/app/_shell/providers/session-provider.test.tsx b/apps/sim/app/_shell/providers/session-provider.test.tsx
index 4f6df2a46eb..2449aa6c2bc 100644
--- a/apps/sim/app/_shell/providers/session-provider.test.tsx
+++ b/apps/sim/app/_shell/providers/session-provider.test.tsx
@@ -1,11 +1,12 @@
/**
* @vitest-environment jsdom
*/
-import { act, useContext } from 'react'
+import { act, type ComponentType, lazy, Suspense, useContext } from 'react'
import { createDeferred } from '@sim/testing/helpers/deferred'
import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { createRoot, type Root } from 'react-dom/client'
+import { createRoot, hydrateRoot, type Root } from 'react-dom/client'
+import { renderToString } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/auth/client', () => authClientMock)
@@ -250,4 +251,39 @@ describe('SessionProvider', () => {
invalidateSpy.mockRestore()
h.unmount()
})
+
+ it('keeps server-rendered rows when the session settles while their chunk is still loading', async () => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ setSearch('')
+ const session = createDeferred<{ data: AppSession }>()
+ mockGetSession.mockReturnValue(session.promise)
+ const chunk = createDeferred<{ default: ComponentType }>()
+ const Row = () =>
+ const Chunked = lazy(() => chunk.promise)
+ const page = (Content: ComponentType) => (
+
+
+
+
+
+
+
+
+
+ )
+ const container = document.createElement('div')
+ container.innerHTML = renderToString(page(Row))
+ const served = container.querySelector('img')
+ expect(served).not.toBeNull()
+
+ const root = await act(async () => hydrateRoot(container, page(Chunked)))
+ await flush()
+ await act(async () => session.resolve({ data: FRESH_SESSION }))
+ await flush()
+ await act(async () => chunk.resolve({ default: Row }))
+ await flush()
+
+ expect(container.querySelector('img')).toBe(served)
+ act(() => root.unmount())
+ })
})
diff --git a/apps/sim/app/_shell/providers/session-provider.tsx b/apps/sim/app/_shell/providers/session-provider.tsx
index 96db371eed3..7a2011d9c0a 100644
--- a/apps/sim/app/_shell/providers/session-provider.tsx
+++ b/apps/sim/app/_shell/providers/session-provider.tsx
@@ -1,7 +1,7 @@
'use client'
import type React from 'react'
-import { createContext, useEffect, useMemo } from 'react'
+import { createContext, useDeferredValue, useEffect, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { client } from '@/lib/auth/client'
@@ -150,5 +150,10 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
[data, isPending, error, refetch]
)
- return {children}
+ // The read settles above every route's Suspense boundary. An urgent context change makes React
+ // discard the server HTML of a boundary whose hydration is waiting on its chunk, so each row is
+ // rebuilt and asks for its images again; deferred, the change waits for hydration to finish.
+ const settledValue = useDeferredValue(value)
+
+ return {children}
}
diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts
index 318e7c7ac98..bffd86acf57 100644
--- a/apps/sim/app/api/auth/oauth/credentials/route.ts
+++ b/apps/sim/app/api/auth/oauth/credentials/route.ts
@@ -7,7 +7,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { oauthCredentialsQuerySchema } from '@/lib/api/contracts/credentials'
import { getValidationErrorMessage } from '@/lib/api/server'
import { AuthType, type AuthTypeValue, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
@@ -84,8 +83,6 @@ async function integrationsWithheldFromSession(
* Get credentials for a specific provider
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const { searchParams } = new URL(request.url)
const rawQuery = {
@@ -100,11 +97,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!parseResult.success) {
const refinementError = parseResult.error.issues.find((err) => err.code === 'custom')
if (refinementError) {
- logger.warn(`[${requestId}] Invalid query parameters: ${refinementError.message}`)
+ logger.warn(`Invalid query parameters: ${refinementError.message}`)
return NextResponse.json({ error: refinementError.message }, { status: 400 })
}
- logger.warn(`[${requestId}] Invalid query parameters`, {
+ logger.warn('Invalid query parameters', {
errors: parseResult.error.issues,
})
@@ -119,7 +116,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
// Authenticate requester (supports session and internal JWT)
const authResult = await checkSessionOrInternalAuth(request)
if (!authResult.success || !authResult.userId) {
- logger.warn(`[${requestId}] Unauthenticated credentials request rejected`)
+ logger.warn('Unauthenticated credentials request rejected')
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const requesterUserId = authResult.userId
@@ -132,7 +129,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
action: 'read',
})
if (!workflowAuthorization.allowed) {
- logger.warn(`[${requestId}] Forbidden credentials request for workflow`, {
+ logger.warn('Forbidden credentials request for workflow', {
requesterUserId,
workflowId,
status: workflowAuthorization.status,
@@ -378,7 +375,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ credentials: [] }, { status: 200 })
} catch (error) {
- logger.error(`[${requestId}] Error fetching OAuth credentials`, error)
+ logger.error('Error fetching OAuth credentials', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts
index e6f7015b1bf..99e7dcc986b 100644
--- a/apps/sim/app/api/auth/oauth/token/route.ts
+++ b/apps/sim/app/api/auth/oauth/token/route.ts
@@ -33,7 +33,7 @@ const logger = createLogger('OAuthTokenAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
- logger.info(`[${requestId}] OAuth token API POST request received`)
+ logger.info('OAuth token API POST request received')
try {
const parsed = await parseRequest(
@@ -42,7 +42,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{},
{
validationErrorResponse: (error) => {
- logger.warn(`[${requestId}] Invalid token request`, { errors: error.issues })
+ logger.warn('Invalid token request', { errors: error.issues })
return NextResponse.json(
{ error: getValidationErrorMessage(error, 'Validation failed') },
{ status: 400 }
@@ -64,14 +64,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const callerUserId = parsed.data.query.userId
if (credentialAccountUserId && providerId) {
- logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
+ logger.info('Fetching token by credentialAccountUserId + providerId', {
credentialAccountUserId,
providerId,
})
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || auth.authType !== AuthType.SESSION || !auth.userId) {
- logger.warn(`[${requestId}] Unauthorized request for credentialAccountUserId path`, {
+ logger.warn('Unauthorized request for credentialAccountUserId path', {
success: auth.success,
authType: auth.authType,
})
@@ -80,7 +80,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (auth.userId !== credentialAccountUserId) {
logger.warn(
- `[${requestId}] User ${auth.userId} attempted to access credentials for ${credentialAccountUserId}`
+ `User ${auth.userId} attempted to access credentials for ${credentialAccountUserId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
@@ -117,7 +117,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ accessToken }, { status: 200 })
} catch (error) {
const message = getErrorMessage(error, 'Failed to get OAuth token')
- logger.warn(`[${requestId}] OAuth token error: ${message}`)
+ logger.warn(`OAuth token error: ${message}`)
return NextResponse.json({ error: message }, { status: 403 })
}
}
@@ -148,7 +148,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(result.token, { status: 200 })
} catch (error) {
- logger.error(`[${requestId}] Error getting access token`, error)
+ logger.error('Error getting access token', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -166,7 +166,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
{},
{
validationErrorResponse: (error) => {
- logger.warn(`[${requestId}] Invalid query parameters`, { errors: error.issues })
+ logger.warn('Invalid query parameters', { errors: error.issues })
return NextResponse.json(
{ error: getValidationErrorMessage(error, 'Validation failed') },
{ status: 400 }
@@ -203,7 +203,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
}
if (!credential.accessToken) {
- logger.warn(`[${requestId}] No access token available for credential`)
+ logger.warn('No access token available for credential')
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
}
@@ -225,7 +225,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(result.token, { status: 200 })
} catch (error) {
- logger.error(`[${requestId}] Error fetching access token`, error)
+ logger.error('Error fetching access token', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts
index ce7af14c86a..76ca4f278b6 100644
--- a/apps/sim/app/api/billing/update-cost/route.ts
+++ b/apps/sim/app/api/billing/update-cost/route.ts
@@ -196,12 +196,12 @@ async function updateCostInner(req: NextRequest, span: Span): Promise {
- logger.warn(`[${requestId}] Invalid request body`, {
+ logger.warn('Invalid request body', {
errors: error.issues,
})
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.InvalidBody)
@@ -324,7 +324,7 @@ async function updateCostInner(req: NextRequest, span: Span): Promise }) => {
const { identifier } = await context.params
- const requestId = generateRequestId()
try {
const parsed = await parseRequest(verifyChatEmailOtpContract, request, context, {
@@ -184,7 +183,7 @@ export const PUT = withRouteHandler(
.limit(1)
if (deploymentResult.length === 0) {
- logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
+ logger.warn(`Chat not found for identifier: ${identifier}`)
return createCodedErrorResponse('Chat not found', 404)
}
@@ -206,14 +205,14 @@ export const PUT = withRouteHandler(
if (attempts >= MAX_OTP_ATTEMPTS) {
await deleteOTP('chat', deployment.id, email)
- logger.warn(`[${requestId}] OTP already locked out for ${email}`)
+ logger.warn(`OTP already locked out for ${email}`)
return createCodedErrorResponse('Too many failed attempts. Please request a new code.', 429)
}
if (storedOTP !== otp) {
const result = await incrementOTPAttempts('chat', deployment.id, email, storedValue)
if (result === 'locked') {
- logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`)
+ logger.warn(`OTP invalidated after max failed attempts for ${email}`)
return createCodedErrorResponse(
'Too many failed attempts. Please request a new code.',
429
@@ -238,7 +237,7 @@ export const PUT = withRouteHandler(
return response
} catch (error) {
- logger.error(`[${requestId}] Error verifying OTP:`, error)
+ logger.error('Error verifying OTP:', error)
return createCodedErrorResponse('Failed to process request', 500)
}
}
diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts
index f2f00ac62ee..ae99d4ade4d 100644
--- a/apps/sim/app/api/chat/[identifier]/route.ts
+++ b/apps/sim/app/api/chat/[identifier]/route.ts
@@ -152,14 +152,14 @@ export const POST = withRouteHandler(
.limit(1)
if (deploymentResult.length === 0) {
- logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
+ logger.warn(`Chat not found for identifier: ${identifier}`)
return createCodedErrorResponse('Chat not found', 404)
}
const deployment = deploymentResult[0]
if (!deployment.isActive) {
- logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
+ logger.warn(`Chat is not active: ${identifier}`)
return createCodedErrorResponse('This chat is currently unavailable', 403)
}
@@ -231,7 +231,7 @@ export const POST = withRouteHandler(
})
if (!preprocessResult.success) {
- logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`)
+ logger.warn(`Preprocessing failed: ${preprocessResult.error?.message}`)
return createCodedErrorResponse(
preprocessResult.error?.message || 'Failed to process request',
preprocessResult.error?.statusCode || 500
@@ -242,7 +242,7 @@ export const POST = withRouteHandler(
const resolvedActorUserId = actorUserId!
const workspaceId = workflowRecord?.workspaceId
if (!workspaceId) {
- logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`)
+ logger.error(`Workflow ${deployment.workflowId} has no workspaceId`)
// preprocessExecution reserved a billing concurrency slot; release it on
// this early exit since no LoggingSession will finalize to free it.
await releaseExecutionSlot(executionId)
@@ -289,10 +289,10 @@ export const POST = withRouteHandler(
if (uploadedFiles.length > 0) {
workflowInput.files = uploadedFiles
- logger.info(`[${requestId}] Successfully processed ${uploadedFiles.length} files`)
+ logger.info(`Successfully processed ${uploadedFiles.length} files`)
}
} catch (fileError: any) {
- logger.error(`[${requestId}] Failed to process chat files:`, fileError)
+ logger.error('Failed to process chat files:', fileError)
await loggingSession.safeStart({
userId: resolvedActorUserId,
@@ -402,14 +402,14 @@ export const POST = withRouteHandler(
})
return streamResponse
} catch (error: any) {
- logger.error(`[${requestId}] Error processing chat request:`, error)
+ logger.error('Error processing chat request:', error)
// Setup failed before the workflow stream took over slot release;
// free the reserved billing slot (idempotent if already released).
await releaseExecutionSlot(executionId)
return createCodedErrorResponse(error.message || 'Failed to process request', 500)
}
} catch (error: any) {
- logger.error(`[${requestId}] Error processing chat request:`, error)
+ logger.error('Error processing chat request:', error)
return createCodedErrorResponse(error.message || 'Failed to process request', 500)
} finally {
ticket.release()
@@ -443,28 +443,26 @@ export const GET = withRouteHandler(
.limit(1)
if (deploymentResult.length === 0) {
- logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
+ logger.warn(`Chat not found for identifier: ${identifier}`)
return createCodedErrorResponse('Chat not found', 404)
}
const deployment = deploymentResult[0]
if (!deployment.isActive) {
- logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
+ logger.warn(`Chat is not active: ${identifier}`)
return createCodedErrorResponse('This chat is currently unavailable', 403)
}
const authResult = await validateChatAuth(requestId, deployment, request)
if (!authResult.authorized) {
- logger.info(
- `[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}`
- )
+ logger.info(`Authentication required for chat: ${identifier}, type: ${deployment.authType}`)
return createCodedErrorResponse(authResult.error || 'Authentication required', 401)
}
return createSuccessResponse(toChatConfigResponse(deployment))
} catch (error: any) {
- logger.error(`[${requestId}] Error fetching chat info:`, error)
+ logger.error('Error fetching chat info:', error)
return createCodedErrorResponse(error.message || 'Failed to fetch chat information', 500)
}
}
diff --git a/apps/sim/app/api/chat/[identifier]/sso/route.ts b/apps/sim/app/api/chat/[identifier]/sso/route.ts
index 82a525b75f6..725a2dd97b9 100644
--- a/apps/sim/app/api/chat/[identifier]/sso/route.ts
+++ b/apps/sim/app/api/chat/[identifier]/sso/route.ts
@@ -8,7 +8,7 @@ import { parseRequest } from '@/lib/api/server'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { isEmailAllowed } from '@/lib/core/security/deployment'
-import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
+import { getClientIp } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createCodedErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -39,8 +39,6 @@ function rateLimited(retryAfterMs: number | undefined, fallbackMs: number) {
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
- const requestId = generateRequestId()
-
const ip = getClientIp(request)
if (ip) {
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
@@ -49,7 +47,7 @@ export const POST = withRouteHandler(
{ failClosed: true }
)
if (!ipRateLimit.allowed) {
- logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
+ logger.warn(`SSO eligibility rate limit exceeded from ${ip}`)
return rateLimited(ipRateLimit.retryAfterMs, SSO_IP_RATE_LIMIT.refillIntervalMs)
}
}
@@ -72,7 +70,7 @@ export const POST = withRouteHandler(
.limit(1)
if (!deployment || !deployment.isActive) {
- logger.warn(`[${requestId}] SSO check on missing/inactive chat: ${identifier}`)
+ logger.warn(`SSO check on missing/inactive chat: ${identifier}`)
return createCodedErrorResponse('Chat not found', 404)
}
@@ -86,7 +84,7 @@ export const POST = withRouteHandler(
{ failClosed: true }
)
if (!resourceRateLimit.allowed) {
- logger.warn(`[${requestId}] SSO eligibility resource rate limit exceeded`, {
+ logger.warn('SSO eligibility resource rate limit exceeded', {
deploymentId: deployment.id,
})
return rateLimited(resourceRateLimit.retryAfterMs, SSO_RESOURCE_RATE_LIMIT.refillIntervalMs)
diff --git a/apps/sim/app/api/contact/route.ts b/apps/sim/app/api/contact/route.ts
index 69df8e97457..066ffaac11c 100644
--- a/apps/sim/app/api/contact/route.ts
+++ b/apps/sim/app/api/contact/route.ts
@@ -11,7 +11,7 @@ import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { isTurnstileConfigured, verifyTurnstileToken } from '@/lib/core/security/turnstile'
-import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
+import { getClientIp } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -50,12 +50,10 @@ const TOO_MANY_REQUESTS_RESPONSE = { error: 'Too many requests. Please try again
* tokens.
*/
export const POST = withRouteHandler(async (req: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const ip = getClientIp(req)
if (!ip) {
- logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`)
+ logger.warn('Unable to resolve client IP for public rate limit')
return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 })
}
const storageKey = `public:contact:${ip}`
@@ -65,7 +63,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
if (!allowed) {
- logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
+ logger.warn(`Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, {
status: 429,
headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
@@ -74,7 +72,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const parsed = await parseRequest(submitContactContract, req, {})
if (!parsed.success) {
- logger.warn(`[${requestId}] Invalid contact request data`)
+ logger.warn('Invalid contact request data')
return parsed.response
}
@@ -82,7 +80,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
parsed.data.body
if (typeof website === 'string' && website.trim().length > 0) {
- logger.warn(`[${requestId}] Honeypot triggered, discarding`, { ip })
+ logger.warn('Honeypot triggered, discarding', { ip })
return NextResponse.json(SUCCESS_RESPONSE, { status: 201 })
}
@@ -96,7 +94,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
if (verification.success) {
captchaVerified = true
} else if (!verification.transportError) {
- logger.warn(`[${requestId}] Captcha verification failed`, {
+ logger.warn('Captcha verification failed', {
ip,
errorCodes: verification.errorCodes,
})
@@ -105,10 +103,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
{ status: 400 }
)
} else {
- logger.warn(
- `[${requestId}] Captcha transport error, falling back to no-captcha rate limit`,
- { ip }
- )
+ logger.warn('Captcha transport error, falling back to no-captcha rate limit', { ip })
}
}
@@ -120,7 +115,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
{ failClosed: true }
)
if (!nocaptchaAllowed) {
- logger.warn(`[${requestId}] Rate limit rejected (no-captcha) for IP ${ip}`)
+ logger.warn(`Rate limit rejected (no-captcha) for IP ${ip}`)
return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 })
}
}
@@ -128,7 +123,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const topicLabel = getContactTopicLabel(topic)
- logger.info(`[${requestId}] Processing contact request`, {
+ logger.info('Processing contact request', {
email: `${email.substring(0, 3)}***`,
topic,
})
@@ -157,11 +152,11 @@ ${message}
})
if (!emailResult.success) {
- logger.error(`[${requestId}] Error sending contact request email`, emailResult.message)
+ logger.error('Error sending contact request email', emailResult.message)
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 })
}
- logger.info(`[${requestId}] Contact request email sent successfully`)
+ logger.info('Contact request email sent successfully')
try {
const confirmationHtml = await renderHelpConfirmationEmail(
@@ -178,17 +173,17 @@ ${message}
emailType: 'transactional',
})
} catch (err) {
- logger.warn(`[${requestId}] Failed to send contact confirmation email`, err)
+ logger.warn('Failed to send contact confirmation email', err)
}
return NextResponse.json(SUCCESS_RESPONSE, { status: 201 })
} catch (error) {
if (error instanceof Error && error.message.includes('not configured')) {
- logger.error(`[${requestId}] Email service configuration error`, error)
+ logger.error('Email service configuration error', error)
return NextResponse.json({ error: 'Email service configuration error.' }, { status: 500 })
}
- logger.error(`[${requestId}] Error processing contact request`, error)
+ logger.error('Error processing contact request', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts
index 286b9e2da3f..02d96580eeb 100644
--- a/apps/sim/app/api/copilot/confirm/route.ts
+++ b/apps/sim/app/api/copilot/confirm/route.ts
@@ -502,7 +502,8 @@ export const POST = withRouteHandler((req: NextRequest) => {
}
if (reconciledOutcome !== 'updated') {
- logger.error(`[${tracker.requestId}] Failed to update tool call status`, {
+ logger.error('Failed to update tool call status', {
+ requestId: tracker.requestId,
userId: authenticatedUserId,
toolCallId,
status: effectiveStatus,
@@ -523,7 +524,8 @@ export const POST = withRouteHandler((req: NextRequest) => {
} catch (error) {
const duration = tracker.getDuration()
- logger.error(`[${tracker.requestId}] Unexpected error:`, {
+ logger.error('Unexpected error:', {
+ requestId: tracker.requestId,
duration,
error: getErrorMessage(error, 'Unknown error'),
stack: error instanceof Error ? error.stack : undefined,
diff --git a/apps/sim/app/api/copilot/feedback/route.ts b/apps/sim/app/api/copilot/feedback/route.ts
index e973145c4f0..bc42f69c40a 100644
--- a/apps/sim/app/api/copilot/feedback/route.ts
+++ b/apps/sim/app/api/copilot/feedback/route.ts
@@ -40,7 +40,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
{
invalidJson: 'throw',
validationErrorResponse: (error) => {
- logger.error(`[${tracker.requestId}] Validation error:`, {
+ logger.error('Validation error:', {
+ requestId: tracker.requestId,
duration: tracker.getDuration(),
errors: error.issues,
})
@@ -53,7 +54,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const { chatId, userQuery, agentResponse, isPositiveFeedback, feedback, workflowYaml } =
parsed.data.body
- logger.info(`[${tracker.requestId}] Processing copilot feedback submission`, {
+ logger.info('Processing copilot feedback submission', {
+ requestId: tracker.requestId,
userId: authenticatedUserId,
chatId,
isPositiveFeedback,
@@ -78,7 +80,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
})
.returning()
- logger.info(`[${tracker.requestId}] Successfully saved copilot feedback`, {
+ logger.info('Successfully saved copilot feedback', {
+ requestId: tracker.requestId,
feedbackId: feedbackRecord.feedbackId,
userId: authenticatedUserId,
isPositive: isPositiveFeedback,
@@ -103,7 +106,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
} catch (error) {
const duration = tracker.getDuration()
- logger.error(`[${tracker.requestId}] Error submitting copilot feedback:`, {
+ logger.error('Error submitting copilot feedback:', {
+ requestId: tracker.requestId,
duration,
error: getErrorMessage(error, 'Unknown error'),
stack: error instanceof Error ? error.stack : undefined,
@@ -145,7 +149,9 @@ export const GET = withRouteHandler(async () => {
.from(copilotFeedback)
.where(eq(copilotFeedback.userId, authenticatedUserId))
- logger.info(`[${tracker.requestId}] Retrieved ${feedbackRecords.length} feedback records`)
+ logger.info(`Retrieved ${feedbackRecords.length} feedback records`, {
+ requestId: tracker.requestId,
+ })
return NextResponse.json({
success: true,
@@ -156,7 +162,7 @@ export const GET = withRouteHandler(async () => {
},
})
} catch (error) {
- logger.error(`[${tracker.requestId}] Error retrieving copilot feedback:`, error)
+ logger.error('Error retrieving copilot feedback:', error, { requestId: tracker.requestId })
return createInternalServerErrorResponse('Failed to retrieve feedback')
}
})
diff --git a/apps/sim/app/api/copilot/tool-permission/route.ts b/apps/sim/app/api/copilot/tool-permission/route.ts
index 86691d296cd..a9121dbc06a 100644
--- a/apps/sim/app/api/copilot/tool-permission/route.ts
+++ b/apps/sim/app/api/copilot/tool-permission/route.ts
@@ -202,7 +202,8 @@ export const POST = withRouteHandler((req: NextRequest) => {
return NextResponse.json({ success: true, results })
} catch (error) {
- logger.error(`[${tracker.requestId}] Unexpected error:`, {
+ logger.error('Unexpected error:', {
+ requestId: tracker.requestId,
duration: tracker.getDuration(),
error: getErrorMessage(error, 'Unknown error'),
stack: error instanceof Error ? error.stack : undefined,
diff --git a/apps/sim/app/api/credential-groups/oauth-callback.ts b/apps/sim/app/api/credential-groups/oauth-callback.ts
index f88f8e79a20..ff5eacf7a5a 100644
--- a/apps/sim/app/api/credential-groups/oauth-callback.ts
+++ b/apps/sim/app/api/credential-groups/oauth-callback.ts
@@ -139,6 +139,7 @@ export async function handleCredentialGroupOAuthCallback({
: error instanceof CredentialGroupOAuthError && error.statusCode === 409
? 'configuration_changed'
: 'failed'
+ if (error instanceof CredentialGroupOAuthError && error.failure) status = error.failure
if (identityFailure) {
switch (identityFailure.reason) {
case 'email_unverified':
diff --git a/apps/sim/app/api/demo-requests/route.ts b/apps/sim/app/api/demo-requests/route.ts
index 2c5a1905364..dd101b3b3e9 100644
--- a/apps/sim/app/api/demo-requests/route.ts
+++ b/apps/sim/app/api/demo-requests/route.ts
@@ -9,7 +9,7 @@ import { parseRequest } from '@/lib/api/server'
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
-import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
+import { getClientIp } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -25,12 +25,10 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
}
export const POST = withRouteHandler(async (req: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const ip = getClientIp(req)
if (!ip) {
- logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`)
+ logger.warn('Unable to resolve client IP for public rate limit')
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429 }
@@ -43,7 +41,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
if (!allowed) {
- logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
+ logger.warn(`Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
@@ -55,7 +53,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const parsed = await parseRequest(submitDemoRequestContract, req, {})
if (!parsed.success) {
- logger.warn(`[${requestId}] Invalid demo request data`)
+ logger.warn('Invalid demo request data')
return parsed.response
}
@@ -63,7 +61,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
parsed.data.body
const attribution = formatAttributionForNotification((name) => req.cookies.get(name)?.value)
- logger.info(`[${requestId}] Processing demo request`, {
+ logger.info('Processing demo request', {
email: `${companyEmail.substring(0, 3)}***`,
companySize,
})
@@ -89,18 +87,18 @@ ${attribution ? `\nAttribution:\n${attribution}\n` : ''}`
})
if (!emailResult.success) {
- logger.error(`[${requestId}] Error sending demo request email`, emailResult.message)
+ logger.error('Error sending demo request email', emailResult.message)
return NextResponse.json({ error: 'Failed to submit request' }, { status: 500 })
}
- logger.info(`[${requestId}] Demo request email sent successfully`)
+ logger.info('Demo request email sent successfully')
return NextResponse.json(
{ success: true, message: 'Thanks! Our team will reach out shortly.' },
{ status: 201 }
)
} catch (error) {
- logger.error(`[${requestId}] Error processing demo request`, error)
+ logger.error('Error processing demo request', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts
index dbef90ae0e7..f5882faae0f 100644
--- a/apps/sim/app/api/environment/route.ts
+++ b/apps/sim/app/api/environment/route.ts
@@ -10,7 +10,6 @@ import { savePersonalEnvironmentContract } from '@/lib/api/contracts/environment
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { lockPersonalEnvMap } from '@/lib/credentials/env-locks'
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
@@ -20,12 +19,10 @@ import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('EnvironmentAPI')
export const POST = withRouteHandler(async (req: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized environment variables update attempt`)
+ logger.warn('Unauthorized environment variables update attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -35,7 +32,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
{},
{
validationErrorResponse: (error) => {
- logger.warn(`[${requestId}] Invalid environment variables data`, { errors: error.issues })
+ logger.warn('Invalid environment variables data', { errors: error.issues })
return NextResponse.json(
{ error: 'Invalid request data', details: error.issues },
{ status: 400 }
@@ -110,18 +107,16 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
return NextResponse.json({ success: true })
} catch (error) {
- logger.error(`[${requestId}] Error updating environment variables`, error)
+ logger.error('Error updating environment variables', error)
return NextResponse.json({ error: 'Failed to update environment variables' }, { status: 500 })
}
})
export const GET = withRouteHandler(async () => {
- const requestId = generateRequestId()
-
try {
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized environment variables access attempt`)
+ logger.warn('Unauthorized environment variables access attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -145,7 +140,7 @@ export const GET = withRouteHandler(async () => {
const { decrypted } = await decryptSecret(encryptedValue)
return [key, { key, value: decrypted }] as const
} catch (error) {
- logger.error(`[${requestId}] Error decrypting variable ${key}`, error)
+ logger.error(`Error decrypting variable ${key}`, error)
return [key, { key, value: '' }] as const
}
})
@@ -157,7 +152,7 @@ export const GET = withRouteHandler(async () => {
return NextResponse.json({ data: decryptedVariables }, { status: 200 })
} catch (error) {
- logger.error(`[${requestId}] Environment fetch error`, error)
+ logger.error('Environment fetch error', error)
return NextResponse.json({ error: toError(error).message }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts
index 41234edc614..f3cb17a9143 100644
--- a/apps/sim/app/api/files/authorization.ts
+++ b/apps/sim/app/api/files/authorization.ts
@@ -797,12 +797,12 @@ export async function assertToolFileAccess(
routeLogger: ReturnType
): Promise {
if (typeof key !== 'string' || key.length === 0) {
- routeLogger.warn(`[${requestId}] File access check rejected: missing key`)
+ routeLogger.warn('File access check rejected: missing key', { requestId })
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
const hasAccess = await verifyFileAccess(key, userId)
if (!hasAccess) {
- routeLogger.warn(`[${requestId}] File access denied for user`, { userId, key })
+ routeLogger.warn('File access denied for user', { requestId, userId, key })
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
return null
diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts
index 9837d594961..7e67f1478fc 100644
--- a/apps/sim/app/api/files/export/[id]/route.ts
+++ b/apps/sim/app/api/files/export/[id]/route.ts
@@ -26,7 +26,7 @@ import {
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
import { getWorkspaceFileSize } from '@/lib/uploads/shared/types'
import { storedFileId } from '@/lib/uploads/utils/embedded-image-ref'
-import { formatFileSize } from '@/lib/uploads/utils/file'
+import { formatFileSize } from '@/lib/uploads/utils/file-size'
import { verifyFileAccess } from '@/app/api/files/authorization'
const logger = createLogger('FilesExportAPI')
diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts
index a13ae1464cd..737ce8af5fb 100644
--- a/apps/sim/app/api/folders/[id]/duplicate/route.ts
+++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts
@@ -58,7 +58,7 @@ export const POST = withRouteHandler(
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized folder duplication attempt for ${sourceFolderId}`)
+ logger.warn(`Unauthorized folder duplication attempt for ${sourceFolderId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -67,7 +67,7 @@ export const POST = withRouteHandler(
if (!parsed.success) return parsed.response
const { name, workspaceId, parentId, newId: clientNewId } = parsed.data.body
- logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
+ logger.info(`Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
const sourceFolder = await db
.select()
@@ -212,7 +212,7 @@ export const POST = withRouteHandler(
const elapsed = Date.now() - startTime
logger.info(
- `[${requestId}] Successfully duplicated folder ${sourceFolderId} to ${newFolderId} in ${elapsed}ms`,
+ `Successfully duplicated folder ${sourceFolderId} to ${newFolderId} in ${elapsed}ms`,
{
foldersCount: folderMapping.size,
workflowsCount: workflowStats.total,
@@ -252,7 +252,7 @@ export const POST = withRouteHandler(
}
if (error instanceof FolderDuplicationError) {
- logger.warn(`[${requestId}] Folder duplication rejected: ${error.message}`, {
+ logger.warn(`Folder duplication rejected: ${error.message}`, {
sourceFolderId,
userId: session.user.id,
})
@@ -267,7 +267,7 @@ export const POST = withRouteHandler(
*/
const orchestrationError = asOrchestrationError(error)
if (orchestrationError) {
- logger.warn(`[${requestId}] Folder duplication rejected: ${orchestrationError.message}`, {
+ logger.warn(`Folder duplication rejected: ${orchestrationError.message}`, {
sourceFolderId,
userId: session.user.id,
})
@@ -278,10 +278,7 @@ export const POST = withRouteHandler(
}
const elapsed = Date.now() - startTime
- logger.error(
- `[${requestId}] Error duplicating folder ${sourceFolderId} after ${elapsed}ms:`,
- error
- )
+ logger.error(`Error duplicating folder ${sourceFolderId} after ${elapsed}ms:`, error)
return NextResponse.json({ error: 'Failed to duplicate folder' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts
index 88408b7a2c1..3d5525d230f 100644
--- a/apps/sim/app/api/folders/reorder/route.ts
+++ b/apps/sim/app/api/folders/reorder/route.ts
@@ -7,7 +7,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { reorderFoldersContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withTransactionRetry } from '@/lib/db/transaction'
import { acquireFolderMutationLock } from '@/lib/folders/locks'
@@ -17,11 +16,10 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FolderReorderAPI')
export const PUT = withRouteHandler(async (req: NextRequest) => {
- const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized folder reorder attempt`)
+ logger.warn('Unauthorized folder reorder attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -32,9 +30,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (!permission || permission === 'read') {
- logger.warn(
- `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
- )
+ logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`)
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
}
@@ -171,7 +167,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
}
logger.info(
- `[${requestId}] Reordered ${validUpdates.length} ${resourceType} folders in workspace ${workspaceId}`
+ `Reordered ${validUpdates.length} ${resourceType} folders in workspace ${workspaceId}`
)
return NextResponse.json({ success: true, updated: validUpdates.length })
@@ -194,7 +190,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
)
}
- logger.error(`[${requestId}] Error reordering folders`, error)
+ logger.error('Error reordering folders', error)
return NextResponse.json({ error: 'Failed to reorder folders' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/help/integration-request/route.ts b/apps/sim/app/api/help/integration-request/route.ts
index 19084dc1e16..9634bf2732b 100644
--- a/apps/sim/app/api/help/integration-request/route.ts
+++ b/apps/sim/app/api/help/integration-request/route.ts
@@ -5,7 +5,7 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
-import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
+import { getClientIp } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -22,12 +22,10 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
}
export const POST = withRouteHandler(async (req: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const ip = getClientIp(req)
if (!ip) {
- logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`)
+ logger.warn('Unable to resolve client IP for public rate limit')
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429 }
@@ -40,7 +38,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
if (!allowed) {
- logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
+ logger.warn(`Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
@@ -62,7 +60,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const { integrationName, email, useCase } = parsed.data.body
- logger.info(`[${requestId}] Processing integration request`, {
+ logger.info('Processing integration request', {
integrationName,
email: `${email.substring(0, 3)}***`,
})
@@ -84,11 +82,11 @@ ${useCase ? `Use Case:\n${useCase}` : 'No use case provided.'}
})
if (!emailResult.success) {
- logger.error(`[${requestId}] Error sending integration request email`, emailResult.message)
+ logger.error('Error sending integration request email', emailResult.message)
return NextResponse.json({ error: 'Failed to send request' }, { status: 500 })
}
- logger.info(`[${requestId}] Integration request email sent successfully`)
+ logger.info('Integration request email sent successfully')
return NextResponse.json(
{ success: true, message: 'Integration request submitted successfully' },
@@ -96,14 +94,14 @@ ${useCase ? `Use Case:\n${useCase}` : 'No use case provided.'}
)
} catch (error) {
if (error instanceof Error && error.message.includes('not configured')) {
- logger.error(`[${requestId}] Email service configuration error`, error)
+ logger.error('Email service configuration error', error)
return NextResponse.json(
{ error: 'Email service is temporarily unavailable. Please try again later.' },
{ status: 500 }
)
}
- logger.error(`[${requestId}] Error processing integration request`, error)
+ logger.error('Error processing integration request', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts
index 3bbb7fef636..e190ac5dfd4 100644
--- a/apps/sim/app/api/help/route.ts
+++ b/apps/sim/app/api/help/route.ts
@@ -4,7 +4,6 @@ import { getRequestConfirmationSubject, renderHelpConfirmationEmail } from '@/co
import { helpFormBodySchema } from '@/lib/api/contracts/common'
import { validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { generateRequestId } from '@/lib/core/utils/request'
import {
isMultipartFieldValidationError,
isPayloadSizeLimitError,
@@ -27,12 +26,10 @@ const logger = createLogger('HelpAPI')
const MAX_HELP_FORM_BYTES = MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES
export const POST = withRouteHandler(async (req: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const session = await getSession()
if (!session?.user?.email) {
- logger.warn(`[${requestId}] Unauthorized help request attempt`)
+ logger.warn('Unauthorized help request attempt')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
@@ -50,7 +47,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const workspaceId = formData.get('workspaceId') as string
const userAgent = formData.get('userAgent') as string | null
- logger.info(`[${requestId}] Processing help request`, {
+ logger.info('Processing help request', {
type,
email: `${email.substring(0, 3)}***`, // Log partial email for privacy
})
@@ -62,7 +59,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
})
if (!validationResult.success) {
- logger.warn(`[${requestId}] Invalid help request data`, {
+ logger.warn('Invalid help request data', {
issues: validationResult.error.issues,
})
return validationErrorResponse(validationResult.error)
@@ -117,11 +114,11 @@ ${message}
})
if (!emailResult.success) {
- logger.error(`[${requestId}] Error sending help request email`, emailResult.message)
+ logger.error('Error sending help request email', emailResult.message)
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 })
}
- logger.info(`[${requestId}] Help request email sent successfully`)
+ logger.info('Help request email sent successfully')
try {
const confirmationHtml = await renderHelpConfirmationEmail(
@@ -138,7 +135,7 @@ ${message}
emailType: 'transactional',
})
} catch (err) {
- logger.warn(`[${requestId}] Failed to send confirmation email`, err)
+ logger.warn('Failed to send confirmation email', err)
}
return NextResponse.json(
@@ -150,14 +147,14 @@ ${message}
return NextResponse.json({ error: error.message }, { status: 400 })
}
if (isPayloadSizeLimitError(error)) {
- logger.warn(`[${requestId}] Help request form data too large`, { message: error.message })
+ logger.warn('Help request form data too large', { message: error.message })
return NextResponse.json(
{ error: `Request body exceeds the maximum allowed size of ${MAX_HELP_FORM_BYTES} bytes` },
{ status: 413 }
)
}
if (error instanceof Error && error.message.includes('not configured')) {
- logger.error(`[${requestId}] Email service configuration error`, error)
+ logger.error('Email service configuration error', error)
return NextResponse.json(
{
error:
@@ -167,7 +164,7 @@ ${message}
)
}
- logger.error(`[${requestId}] Error processing help request`, error)
+ logger.error('Error processing help request', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/jobs/[jobId]/route.ts b/apps/sim/app/api/jobs/[jobId]/route.ts
index 73015ddb79b..5a8aa084b5f 100644
--- a/apps/sim/app/api/jobs/[jobId]/route.ts
+++ b/apps/sim/app/api/jobs/[jobId]/route.ts
@@ -6,7 +6,6 @@ import { parseRequest } from '@/lib/api/server'
import { WORKSPACE_KEY_SCOPE_DENIED } from '@/lib/api-key/policy-messages'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getJobQueue } from '@/lib/core/async-jobs'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createCodedErrorResponse } from '@/app/api/workflows/utils'
@@ -17,12 +16,11 @@ export const GET = withRouteHandler(
const parsed = await parseRequest(getJobStatusContract, request, context)
if (!parsed.success) return parsed.response
const { jobId: taskId } = parsed.data.params
- const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
- logger.warn(`[${requestId}] Unauthorized task status request`)
+ logger.warn('Unauthorized task status request')
return createCodedErrorResponse(authResult.error || 'Authentication required', 401)
}
@@ -47,7 +45,7 @@ export const GET = withRouteHandler(
action: 'read',
})
if (!accessCheck.allowed) {
- logger.warn(`[${requestId}] Access denied to workflow ${metadataToCheck.workflowId}`)
+ logger.warn(`Access denied to workflow ${metadataToCheck.workflowId}`)
return createCodedErrorResponse('Access denied', 403)
}
@@ -59,10 +57,10 @@ export const GET = withRouteHandler(
}
}
} else if (metadataToCheck?.userId && metadataToCheck.userId !== authenticatedUserId) {
- logger.warn(`[${requestId}] Access denied to user ${metadataToCheck.userId}`)
+ logger.warn(`Access denied to user ${metadataToCheck.userId}`)
return createCodedErrorResponse('Access denied', 403)
} else if (!metadataToCheck?.userId && !metadataToCheck?.workflowId) {
- logger.warn(`[${requestId}] Access denied to job ${taskId}`)
+ logger.warn(`Access denied to job ${taskId}`)
return createCodedErrorResponse('Access denied', 403)
}
@@ -79,7 +77,7 @@ export const GET = withRouteHandler(
return NextResponse.json(response)
} catch (error: unknown) {
const errorMessage = toError(error).message
- logger.error(`[${requestId}] Error fetching task status:`, error)
+ logger.error('Error fetching task status:', error)
if (errorMessage?.includes('not found')) {
return createCodedErrorResponse('Task not found', 404)
diff --git a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts
index d7af3b8abc0..562c8d1e639 100644
--- a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts
+++ b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts
@@ -105,7 +105,7 @@ function logRowNotHeldByLiveRun(staleCutoff: Date): SQL {
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
- logger.info(`[${requestId}] Connector member sync scheduler triggered`)
+ logger.info('Connector member sync scheduler triggered')
const authError = verifyCronAuth(request, 'Connector member sync scheduler')
if (authError) return authError
@@ -157,28 +157,28 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
])
if (recoveredRunning.length > 0) {
- logger.warn(`[${requestId}] Recovered ${recoveredRunning.length} stale member sync run(s)`, {
+ logger.warn(`Recovered ${recoveredRunning.length} stale member sync run(s)`, {
ids: recoveredRunning.map((row) => row.id),
})
}
if (recoveredPending.length > 0) {
logger.warn(
- `[${requestId}] Recovered ${recoveredPending.length} connector(s) whose queued member sync never started`,
+ `Recovered ${recoveredPending.length} connector(s) whose queued member sync never started`,
{ ids: recoveredPending.map((row) => row.id) }
)
}
if (closedLogs.length > 0) {
- logger.warn(`[${requestId}] Closed ${closedLogs.length} orphaned member sync log(s)`)
+ logger.warn(`Closed ${closedLogs.length} orphaned member sync log(s)`)
}
/** Observation hygiene never holds back dispatch; an unfinished sweep resumes next tick. */
try {
const sweep = await sweepStaleMemberObservations(now)
if (sweep.members > 0) {
- logger.warn(`[${requestId}] Swept observations of ${sweep.members} stale member(s)`, sweep)
+ logger.warn(`Swept observations of ${sweep.members} stale member(s)`, sweep)
}
} catch (error) {
- logger.error(`[${requestId}] Stale member observation sweep failed`, {
+ logger.error('Stale member observation sweep failed', {
error: getErrorMessage(error),
})
}
@@ -207,7 +207,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
.orderBy(asc(knowledgeConnector.nextMemberSyncAt))
.limit(MAX_DISPATCHES_PER_TICK)
- logger.info(`[${requestId}] Found ${dueConnectors.length} connectors due for member sync`)
+ logger.info(`Found ${dueConnectors.length} connectors due for member sync`)
if (dueConnectors.length === 0) {
return NextResponse.json({
@@ -231,10 +231,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
requireRunnable: true,
})
} catch (error) {
- logger.error(
- `[${requestId}] Failed to dispatch member sync for connector ${connector.id}`,
- error
- )
+ logger.error(`Failed to dispatch member sync for connector ${connector.id}`, error)
}
})
@@ -244,7 +241,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
count: dueConnectors.length,
})
} catch (error) {
- logger.error(`[${requestId}] Connector member sync scheduler error`, error)
+ logger.error('Connector member sync scheduler error', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts
index f0a2f8d4513..338adb3645a 100644
--- a/apps/sim/app/api/knowledge/connectors/sync/route.ts
+++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts
@@ -160,7 +160,7 @@ function reclaimPayload(message: string) {
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
- logger.info(`[${requestId}] Connector sync scheduler triggered`)
+ logger.info('Connector sync scheduler triggered')
const authError = verifyCronAuth(request, 'Connector sync scheduler')
if (authError) {
@@ -283,21 +283,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
])
if (recoveredConnectors.length > 0) {
- logger.warn(
- `[${requestId}] Recovered ${recoveredConnectors.length} stale syncing connectors`,
- { ids: recoveredConnectors.map((c) => c.id) }
- )
+ logger.warn(`Recovered ${recoveredConnectors.length} stale syncing connectors`, {
+ ids: recoveredConnectors.map((c) => c.id),
+ })
}
if (recoveredPendingConnectors.length > 0) {
logger.warn(
- `[${requestId}] Recovered ${recoveredPendingConnectors.length} connectors whose queued sync was never started`,
+ `Recovered ${recoveredPendingConnectors.length} connectors whose queued sync was never started`,
{ ids: recoveredPendingConnectors.map((c) => c.id) }
)
}
if (closedSyncLogs.length > 0) {
- logger.warn(`[${requestId}] Closed ${closedSyncLogs.length} orphaned connector sync log(s)`)
+ logger.warn(`Closed ${closedSyncLogs.length} orphaned connector sync log(s)`)
}
const dueConnectors = await db
@@ -323,7 +322,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
.orderBy(asc(knowledgeConnector.nextSyncAt))
.limit(MAX_DISPATCHES_PER_TICK)
- logger.info(`[${requestId}] Found ${dueConnectors.length} connectors due for sync`)
+ logger.info(`Found ${dueConnectors.length} connectors due for sync`)
if (dueConnectors.length === 0) {
return NextResponse.json({
@@ -347,7 +346,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
requireRunnable: true,
})
} catch (error) {
- logger.error(`[${requestId}] Failed to dispatch sync for connector ${connector.id}`, error)
+ logger.error(`Failed to dispatch sync for connector ${connector.id}`, error)
}
})
@@ -357,7 +356,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
count: dueConnectors.length,
})
} catch (error) {
- logger.error(`[${requestId}] Connector sync scheduler error`, error)
+ logger.error('Connector sync scheduler error', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts
index d560d65b713..73e9ab54703 100644
--- a/apps/sim/app/api/logs/stats/route.ts
+++ b/apps/sim/app/api/logs/stats/route.ts
@@ -5,7 +5,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { type DashboardStatsResponse, statsQueryParamsSchema } from '@/lib/api/contracts/logs'
import { isZodError } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { buildFilterConditions } from '@/lib/logs/filters'
import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion'
@@ -31,12 +30,10 @@ export const revalidate = 0
* check and consumes only the surface-neutral halves.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized logs stats access attempt`)
+ logger.warn('Unauthorized logs stats access attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -105,7 +102,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(stats, { status: 200 })
} catch (validationError) {
if (isZodError(validationError)) {
- logger.warn(`[${requestId}] Invalid logs stats request parameters`, {
+ logger.warn('Invalid logs stats request parameters', {
errors: validationError.issues,
})
return NextResponse.json(
@@ -119,7 +116,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
throw validationError
}
} catch (error: any) {
- logger.error(`[${requestId}] logs stats fetch error`, error)
+ logger.error('logs stats fetch error', error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts
index e356f7a17ee..bd3ff0915ac 100644
--- a/apps/sim/app/api/mcp/oauth/callback/route.ts
+++ b/apps/sim/app/api/mcp/oauth/callback/route.ts
@@ -18,6 +18,7 @@ import {
} from '@/lib/credential-groups/mcp-oauth-state'
import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version'
import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion'
+import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter'
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
import {
assertSafeOauthServerUrl,
@@ -162,7 +163,9 @@ async function completeManagedMcpCallback(params: {
})
} catch (error) {
logger.error('Managed MCP OAuth callback failed', error)
- return failureRedirect('failed')
+ return failureRedirect(
+ error instanceof CredentialGroupOAuthError && error.failure ? error.failure : 'failed'
+ )
}
}
diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts
index f7ec2c4ff23..90138bc1755 100644
--- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts
+++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts
@@ -147,7 +147,8 @@ async function syncToolSchemasToWorkflows(
if (updatedWorkflowIds.size > 0) {
logger.info(
- `[${requestId}] Synced tool schemas to ${updatedWorkflowIds.size} workflow(s) for server ${serverId}`
+ `Synced tool schemas to ${updatedWorkflowIds.size} workflow(s) for server ${serverId}`,
+ { requestId }
)
}
@@ -166,7 +167,7 @@ export const POST = withRouteHandler(
const paramsValidation = mcpServerIdParamsSchema.safeParse(await params)
if (!paramsValidation.success) return validationErrorResponse(paramsValidation.error)
const { id: serverId } = paramsValidation.data
- logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`)
+ logger.info(`Refreshing MCP server: ${serverId}`, { requestId })
const [server] = await db
.select()
@@ -200,14 +201,12 @@ export const POST = withRouteHandler(
workspaceId,
'force'
)
- logger.info(
- `[${requestId}] Discovered ${discoveredTools.length} tools from server ${serverId}`
- )
+ logger.info(`Discovered ${discoveredTools.length} tools from server ${serverId}`, {
+ requestId,
+ })
} catch (error) {
discoveryError = truncate(categorizeError(error).message, 200, '')
- logger.warn(`[${requestId}] Failed to connect to server ${serverId}`, {
- error: discoveryError,
- })
+ logger.warn(`Failed to connect to server ${serverId}`, { requestId, error: discoveryError })
}
if (discoveryError === null) {
@@ -223,7 +222,8 @@ export const POST = withRouteHandler(
// Discovery already persisted status and cached tools; a workflow-sync
// failure is a secondary propagation and must not fail the refresh with
// a 500. Surface it as zero workflows updated instead.
- logger.warn(`[${requestId}] Tool schema sync failed after successful discovery`, {
+ logger.warn('Tool schema sync failed after successful discovery', {
+ requestId,
error: getErrorMessage(error),
})
}
@@ -288,7 +288,7 @@ export const POST = withRouteHandler(
updatedWorkflowIds: syncResult.updatedWorkflowIds,
})
} catch (error) {
- logger.error(`[${requestId}] Error refreshing MCP server:`, error)
+ logger.error('Error refreshing MCP server:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to refresh MCP server', 500)
}
})
diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts
index e13a844f911..5f6cc2bcb11 100644
--- a/apps/sim/app/api/mcp/servers/[id]/route.ts
+++ b/apps/sim/app/api/mcp/servers/[id]/route.ts
@@ -45,13 +45,11 @@ export const PATCH = withRouteHandler(
const body = parsedBody.data
- logger.info(
- `[${requestId}] Updating MCP server: ${serverId} in workspace: ${workspaceId}`,
- {
- userId,
- updates: Object.keys(body).filter((k) => k !== 'workspaceId'),
- }
- )
+ logger.info(`Updating MCP server: ${serverId} in workspace: ${workspaceId}`, {
+ requestId,
+ userId,
+ updates: Object.keys(body).filter((k) => k !== 'workspaceId'),
+ })
const result = await performUpdateMcpServer({
workspaceId,
@@ -84,7 +82,7 @@ export const PATCH = withRouteHandler(
}
const updatedServer = result.server
- logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
+ logger.info(`Successfully updated MCP server: ${serverId}`, { requestId })
/**
* The echoed row never carries header values: the caller just supplied
@@ -97,7 +95,7 @@ export const PATCH = withRouteHandler(
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
- logger.error(`[${requestId}] Error updating MCP server:`, error)
+ logger.error('Error updating MCP server:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to update MCP server', 500)
}
}
diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts
index dde2f6c082c..09e4d248340 100644
--- a/apps/sim/app/api/mcp/servers/route.ts
+++ b/apps/sim/app/api/mcp/servers/route.ts
@@ -34,7 +34,7 @@ export const GET = withRouteHandler(
'mcp_tools.use'
)(async (_request: NextRequest, { workspaceId, requestId, permission }) => {
try {
- logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`)
+ logger.info(`Listing MCP servers for workspace ${workspaceId}`, { requestId })
const rows = await db
.select()
@@ -50,12 +50,12 @@ export const GET = withRouteHandler(
const includeHeaderValues = permissionSatisfies(permission, 'write')
const servers = rows.map((row) => projectInternalMcpServer(row, { includeHeaderValues }))
- logger.info(
- `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}`
- )
+ logger.info(`Listed ${servers.length} MCP servers for workspace ${workspaceId}`, {
+ requestId,
+ })
return createMcpSuccessResponse({ servers })
} catch (error) {
- logger.error(`[${requestId}] Error listing MCP servers:`, error)
+ logger.error('Error listing MCP servers:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to list MCP servers', 500)
}
})
@@ -79,7 +79,8 @@ export const POST = withRouteHandler(
const body = parsedBody.data
- logger.info(`[${requestId}] Registering MCP server:`, {
+ logger.info('Registering MCP server:', {
+ requestId,
name: body.name,
transport: body.transport,
workspaceId,
@@ -124,9 +125,9 @@ export const POST = withRouteHandler(
)
}
- logger.info(
- `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${result.serverId})`
- )
+ logger.info(`Successfully registered MCP server: ${body.name} (ID: ${result.serverId})`, {
+ requestId,
+ })
return createMcpSuccessResponse(
result.updated
@@ -137,7 +138,7 @@ export const POST = withRouteHandler(
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
- logger.error(`[${requestId}] Error registering MCP server:`, error)
+ logger.error('Error registering MCP server:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500)
}
})
@@ -171,7 +172,7 @@ export const DELETE = withRouteHandler(
)
}
- logger.info(`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`)
+ logger.info(`Deleting MCP server: ${serverId} from workspace: ${workspaceId}`, { requestId })
const result = await performDeleteMcpServer({
workspaceId,
@@ -190,11 +191,11 @@ export const DELETE = withRouteHandler(
)
}
- logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
+ logger.info(`Successfully deleted MCP server: ${serverId}`, { requestId })
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
} catch (error) {
- logger.error(`[${requestId}] Error deleting MCP server:`, error)
+ logger.error('Error deleting MCP server:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to delete MCP server', 500)
}
})
diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts
index 3f147f75998..8387c540414 100644
--- a/apps/sim/app/api/mcp/servers/test-connection/route.ts
+++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts
@@ -98,7 +98,8 @@ export const POST = withRouteHandler(
const body = parsedBody.data
- logger.info(`[${requestId}] Testing MCP server connection:`, {
+ logger.info('Testing MCP server connection:', {
+ requestId,
name: body.name,
transport: body.transport,
url: body.url ? `${body.url.substring(0, 50)}...` : undefined, // Partial URL for security
@@ -158,7 +159,7 @@ export const POST = withRouteHandler(
)
if (missingVars.length > 0) {
- logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars })
+ logger.warn('Some environment variables not found:', { requestId, missingVars })
}
// Re-validate domain and SSRF after env var resolution
@@ -226,7 +227,7 @@ export const POST = withRouteHandler(
result.toolCount = tools.length
result.success = true
} catch {
- logger.warn(`[${requestId}] Connection established but could not list tools`)
+ logger.warn('Connection established but could not list tools', { requestId })
result.success = false
result.error = 'Connection established but could not list tools'
result.warnings = result.warnings || []
@@ -243,7 +244,8 @@ export const POST = withRouteHandler(
)
}
- logger.info(`[${requestId}] MCP server test successful:`, {
+ logger.info('MCP server test successful:', {
+ requestId,
name: body.name,
negotiatedVersion: result.negotiatedVersion,
toolCount: result.toolCount,
@@ -252,13 +254,13 @@ export const POST = withRouteHandler(
} catch (error) {
result.success = false
result.error = sanitizeConnectionError(error)
- logger.warn(`[${requestId}] MCP server test failed`, { error: result.error })
+ logger.warn('MCP server test failed', { requestId, error: result.error })
} finally {
if (client) {
try {
await client.disconnect()
} catch {
- logger.debug(`[${requestId}] Test client disconnect error (expected)`)
+ logger.debug('Test client disconnect error (expected)', { requestId })
}
}
}
@@ -267,7 +269,7 @@ export const POST = withRouteHandler(
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
- logger.error(`[${requestId}] Error testing MCP server connection:`, error)
+ logger.error('Error testing MCP server connection:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to test server connection', 500)
}
})
diff --git a/apps/sim/app/api/mcp/tools/discover/route.ts b/apps/sim/app/api/mcp/tools/discover/route.ts
index dded0044831..fd8e7f87e75 100644
--- a/apps/sim/app/api/mcp/tools/discover/route.ts
+++ b/apps/sim/app/api/mcp/tools/discover/route.ts
@@ -38,16 +38,18 @@ export const dynamic = 'force-dynamic'
*/
function discoveryFailureResponse(requestId: string, error: unknown) {
if (isMcpServerUnavailableError(error)) {
- logger.warn(`[${requestId}] MCP server unavailable for tool discovery`, {
+ logger.warn('MCP server unavailable for tool discovery', {
+ requestId,
error: getErrorMessage(error),
})
return createMcpErrorResponse(error, 'Failed to discover MCP tools', 422)
}
const { message, status } = categorizeError(error)
if (status >= 500) {
- logger.error(`[${requestId}] Error discovering MCP tools:`, error)
+ logger.error('Error discovering MCP tools:', error, { requestId })
} else {
- logger.warn(`[${requestId}] Failed to discover MCP tools`, {
+ logger.warn('Failed to discover MCP tools', {
+ requestId,
error: getErrorMessage(error),
status,
})
@@ -101,7 +103,7 @@ export const GET = withRouteHandler(
const serverId = query.serverId
const forceRefresh = query.refresh === 'true'
- logger.info(`[${requestId}] Discovering MCP tools`, { serverId, workspaceId, forceRefresh })
+ logger.info('Discovering MCP tools', { requestId, serverId, workspaceId, forceRefresh })
const tools = serverId
? await mcpService.discoverServerTools(
@@ -127,9 +129,9 @@ export const GET = withRouteHandler(
byServer,
}
- logger.info(
- `[${requestId}] Discovered ${tools.length} tools from ${Object.keys(byServer).length} servers`
- )
+ logger.info(`Discovered ${tools.length} tools from ${Object.keys(byServer).length} servers`, {
+ requestId,
+ })
return createMcpSuccessResponse(responseData)
} catch (error) {
if (
@@ -159,7 +161,7 @@ export const POST = withRouteHandler(
const { serverIds } = parsedBody.data
- logger.info(`[${requestId}] Refreshing tools for ${serverIds.length} servers`)
+ logger.info(`Refreshing tools for ${serverIds.length} servers`, { requestId })
const results = await settleWithConcurrency(
serverIds,
@@ -185,7 +187,7 @@ export const POST = withRouteHandler(
}
})
- logger.info(`[${requestId}] Refresh completed: ${successes.length}/${serverIds.length}`)
+ logger.info(`Refresh completed: ${successes.length}/${serverIds.length}`, { requestId })
return createMcpSuccessResponse({
refreshed: successes,
failed: failures,
@@ -204,7 +206,7 @@ export const POST = withRouteHandler(
) {
return createMcpErrorResponse(error, 'OAuth re-authorization required', 401)
}
- logger.error(`[${requestId}] Error refreshing tool discovery:`, error)
+ logger.error('Error refreshing tool discovery:', error, { requestId })
const { message, status } = categorizeError(error)
return createMcpErrorResponse(new Error(message), 'Failed to refresh tool discovery', status)
}
diff --git a/apps/sim/app/api/mcp/tools/stored/route.ts b/apps/sim/app/api/mcp/tools/stored/route.ts
index a06bdfc1229..5cda364b600 100644
--- a/apps/sim/app/api/mcp/tools/stored/route.ts
+++ b/apps/sim/app/api/mcp/tools/stored/route.ts
@@ -19,7 +19,7 @@ export const GET = withRouteHandler(
'mcp_tools.use'
)(async (_request: NextRequest, { workspaceId, requestId }) => {
try {
- logger.info(`[${requestId}] Fetching stored MCP tools for workspace ${workspaceId}`)
+ logger.info(`Fetching stored MCP tools for workspace ${workspaceId}`, { requestId })
const workflows = await db
.select({ id: workflow.id, name: workflow.name })
@@ -69,12 +69,13 @@ export const GET = withRouteHandler(
}
logger.info(
- `[${requestId}] Found ${storedTools.length} stored MCP tools across ${workflows.length} workflows`
+ `Found ${storedTools.length} stored MCP tools across ${workflows.length} workflows`,
+ { requestId }
)
return createMcpSuccessResponse({ tools: storedTools })
} catch (error) {
- logger.error(`[${requestId}] Error fetching stored MCP tools:`, error)
+ logger.error('Error fetching stored MCP tools:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to fetch stored MCP tools', 500)
}
})
diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts
index 367a2bb6345..1120c4855e0 100644
--- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts
+++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts
@@ -43,7 +43,7 @@ export const GET = withRouteHandler(
try {
const { id: serverId } = workflowMcpServerParamsSchema.parse(await params)
- logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`)
+ logger.info(`Getting workflow MCP server: ${serverId}`, { requestId })
const [server] = await db
.select({
@@ -75,13 +75,13 @@ export const GET = withRouteHandler(
.from(workflowMcpTool)
.where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt)))
- logger.info(
- `[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools`
- )
+ logger.info(`Found workflow MCP server: ${server.name} with ${tools.length} tools`, {
+ requestId,
+ })
return createMcpSuccessResponse({ server, tools })
} catch (error) {
- logger.error(`[${requestId}] Error getting workflow MCP server:`, error)
+ logger.error('Error getting workflow MCP server:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to get workflow MCP server', 500)
}
})
@@ -111,7 +111,7 @@ export const PATCH = withRouteHandler(
const body = parsedBody.data
- logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`)
+ logger.info(`Updating workflow MCP server: ${serverId}`, { requestId })
const result = await performUpdateWorkflowMcpServer({
serverId,
@@ -135,13 +135,13 @@ export const PATCH = withRouteHandler(
const updatedServer = result.server
- logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`)
+ logger.info(`Successfully updated workflow MCP server: ${serverId}`, { requestId })
return createMcpSuccessResponse({ server: updatedServer })
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
- logger.error(`[${requestId}] Error updating workflow MCP server:`, error)
+ logger.error('Error updating workflow MCP server:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to update workflow MCP server', 500)
}
}
@@ -164,7 +164,7 @@ export const DELETE = withRouteHandler(
try {
const { id: serverId } = workflowMcpServerParamsSchema.parse(await params)
- logger.info(`[${requestId}] Deleting workflow MCP server: ${serverId}`)
+ logger.info(`Deleting workflow MCP server: ${serverId}`, { requestId })
const result = await performDeleteWorkflowMcpServer({
serverId,
@@ -180,11 +180,11 @@ export const DELETE = withRouteHandler(
mcpOrchestrationStatus(result.errorCode)
)
}
- logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`)
+ logger.info(`Successfully deleted workflow MCP server: ${serverId}`, { requestId })
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
} catch (error) {
- logger.error(`[${requestId}] Error deleting workflow MCP server:`, error)
+ logger.error('Error deleting workflow MCP server:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to delete workflow MCP server', 500)
}
}
diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts
index 41965115308..22b6aab3fbf 100644
--- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts
+++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts
@@ -41,7 +41,7 @@ export const GET = withRouteHandler(
try {
const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params)
- logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`)
+ logger.info(`Getting tool ${toolId} from server ${serverId}`, { requestId })
const [server] = await db
.select({ id: workflowMcpServer.id })
@@ -77,7 +77,7 @@ export const GET = withRouteHandler(
return createMcpSuccessResponse({ tool })
} catch (error) {
- logger.error(`[${requestId}] Error getting tool:`, error)
+ logger.error('Error getting tool:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to get tool', 500)
}
})
@@ -107,7 +107,7 @@ export const PATCH = withRouteHandler(
const body = parsedBody.data
- logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`)
+ logger.info(`Updating tool ${toolId} in server ${serverId}`, { requestId })
const result = await performUpdateWorkflowMcpTool({
serverId,
@@ -131,13 +131,13 @@ export const PATCH = withRouteHandler(
const updatedTool = result.tool
- logger.info(`[${requestId}] Successfully updated tool ${toolId}`)
+ logger.info(`Successfully updated tool ${toolId}`, { requestId })
return createMcpSuccessResponse({ tool: updatedTool })
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
- logger.error(`[${requestId}] Error updating tool:`, error)
+ logger.error('Error updating tool:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to update tool', 500)
}
}
@@ -160,7 +160,7 @@ export const DELETE = withRouteHandler(
try {
const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params)
- logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`)
+ logger.info(`Deleting tool ${toolId} from server ${serverId}`, { requestId })
const result = await performDeleteWorkflowMcpTool({
serverId,
@@ -177,11 +177,11 @@ export const DELETE = withRouteHandler(
mcpOrchestrationStatus(result.errorCode)
)
}
- logger.info(`[${requestId}] Successfully deleted tool ${toolId}`)
+ logger.info(`Successfully deleted tool ${toolId}`, { requestId })
return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` })
} catch (error) {
- logger.error(`[${requestId}] Error deleting tool:`, error)
+ logger.error('Error deleting tool:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to delete tool', 500)
}
}
diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts
index 49613fc08f6..9991ddc7fb4 100644
--- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts
+++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts
@@ -40,7 +40,7 @@ export const GET = withRouteHandler(
try {
const { id: serverId } = workflowMcpServerParamsSchema.parse(await params)
- logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`)
+ logger.info(`Listing tools for workflow MCP server: ${serverId}`, { requestId })
const [server] = await db
.select({ id: workflowMcpServer.id })
@@ -80,11 +80,11 @@ export const GET = withRouteHandler(
)
.where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt)))
- logger.info(`[${requestId}] Found ${tools.length} tools for server ${serverId}`)
+ logger.info(`Found ${tools.length} tools for server ${serverId}`, { requestId })
return createMcpSuccessResponse({ tools })
} catch (error) {
- logger.error(`[${requestId}] Error listing tools:`, error)
+ logger.error('Error listing tools:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to list tools', 500)
}
})
@@ -114,7 +114,8 @@ export const POST = withRouteHandler(
const body = parsedBody.data
- logger.info(`[${requestId}] Adding tool to workflow MCP server: ${serverId}`, {
+ logger.info(`Adding tool to workflow MCP server: ${serverId}`, {
+ requestId,
workflowId: body.workflowId,
})
@@ -141,14 +142,15 @@ export const POST = withRouteHandler(
const tool = result.tool
logger.info(
- `[${requestId}] Successfully added tool ${tool.toolName} (workflow: ${body.workflowId}) to server ${serverId}`
+ `Successfully added tool ${tool.toolName} (workflow: ${body.workflowId}) to server ${serverId}`,
+ { requestId }
)
return createMcpSuccessResponse({ tool }, 201)
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
- logger.error(`[${requestId}] Error adding tool:`, error)
+ logger.error('Error adding tool:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to add tool', 500)
}
}
diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts
index f7a8f90fb19..29779dd4bb1 100644
--- a/apps/sim/app/api/mcp/workflow-servers/route.ts
+++ b/apps/sim/app/api/mcp/workflow-servers/route.ts
@@ -31,7 +31,7 @@ export const GET = withRouteHandler(
'deploy.mcp'
)(async (_request: NextRequest, { workspaceId, requestId }) => {
try {
- logger.info(`[${requestId}] Listing workflow MCP servers for workspace ${workspaceId}`)
+ logger.info(`Listing workflow MCP servers for workspace ${workspaceId}`, { requestId })
const servers = await db
.select({
@@ -85,12 +85,12 @@ export const GET = withRouteHandler(
toolNames: toolNamesByServer[server.id] || [],
}))
- logger.info(
- `[${requestId}] Listed ${servers.length} workflow MCP servers for workspace ${workspaceId}`
- )
+ logger.info(`Listed ${servers.length} workflow MCP servers for workspace ${workspaceId}`, {
+ requestId,
+ })
return createMcpSuccessResponse({ servers: serversWithToolNames })
} catch (error) {
- logger.error(`[${requestId}] Error listing workflow MCP servers:`, error)
+ logger.error('Error listing workflow MCP servers:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to list workflow MCP servers', 500)
}
})
@@ -118,7 +118,8 @@ export const POST = withRouteHandler(
const body = parsedBody.data
- logger.info(`[${requestId}] Creating workflow MCP server:`, {
+ logger.info('Creating workflow MCP server:', {
+ requestId,
name: body.name,
workspaceId,
workflowIds: body.workflowIds,
@@ -146,15 +147,15 @@ export const POST = withRouteHandler(
const { server } = result
const addedTools = result.addedTools || []
- logger.info(
- `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})`
- )
+ logger.info(`Successfully created workflow MCP server: ${body.name} (ID: ${server.id})`, {
+ requestId,
+ })
return createMcpSuccessResponse({ server, addedTools }, 201)
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
- logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
+ logger.error('Error creating workflow MCP server:', error, { requestId })
return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500)
}
}
diff --git a/apps/sim/app/api/organizations/[id]/home/calendar/route.ts b/apps/sim/app/api/organizations/[id]/home/calendar/route.ts
new file mode 100644
index 00000000000..fbc44c04cef
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/home/calendar/route.ts
@@ -0,0 +1,25 @@
+import { getOrganizationHomeCalendarContract } from '@/lib/api/contracts/organization-home'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ organizationHomeOperations,
+ readOrganizationHomeCalendar,
+} from '@/lib/organizations/application/home-widgets'
+import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
+
+export const GET = defineInternalJsonRoute({
+ contract: getOrganizationHomeCalendarContract,
+ auth: internalSessionAuth,
+ operation: organizationHomeOperations.readCalendar,
+ rateLimit: internalRateLimits.user({ bucketName: 'organization-home-calendar' }),
+ errorPolicy: createCredentialGroupInternalErrorPolicy(
+ 'Failed to load calendar',
+ 'Organization not found'
+ ),
+ mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }),
+ useCase: readOrganizationHomeCalendar,
+ staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
+})
diff --git a/apps/sim/app/api/organizations/[id]/home/email/route.ts b/apps/sim/app/api/organizations/[id]/home/email/route.ts
new file mode 100644
index 00000000000..2286b859f74
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/home/email/route.ts
@@ -0,0 +1,25 @@
+import { getOrganizationHomeEmailContract } from '@/lib/api/contracts/organization-home'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ organizationHomeOperations,
+ readOrganizationHomeEmail,
+} from '@/lib/organizations/application/home-widgets'
+import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
+
+export const GET = defineInternalJsonRoute({
+ contract: getOrganizationHomeEmailContract,
+ auth: internalSessionAuth,
+ operation: organizationHomeOperations.readEmail,
+ rateLimit: internalRateLimits.user({ bucketName: 'organization-home-email' }),
+ errorPolicy: createCredentialGroupInternalErrorPolicy(
+ 'Failed to load email',
+ 'Organization not found'
+ ),
+ mapInput: ({ params }) => ({ organizationId: params.id }),
+ useCase: readOrganizationHomeEmail,
+ staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
+})
diff --git a/apps/sim/app/api/organizations/[id]/home/workspace-activity/route.ts b/apps/sim/app/api/organizations/[id]/home/workspace-activity/route.ts
new file mode 100644
index 00000000000..28ab88fc9d9
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/home/workspace-activity/route.ts
@@ -0,0 +1,25 @@
+import { getOrganizationHomeWorkspaceActivityContract } from '@/lib/api/contracts/organization-home'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ organizationHomeWorkspaceOperations,
+ readOrganizationHomeWorkspaceActivity,
+} from '@/lib/organizations/application/home-workspaces'
+import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
+
+export const GET = defineInternalJsonRoute({
+ contract: getOrganizationHomeWorkspaceActivityContract,
+ auth: internalSessionAuth,
+ operation: organizationHomeWorkspaceOperations.readActivity,
+ rateLimit: internalRateLimits.user({ bucketName: 'organization-home-workspace-activity' }),
+ errorPolicy: createCredentialGroupInternalErrorPolicy(
+ 'Failed to load workspace activity',
+ 'Organization not found'
+ ),
+ mapInput: ({ params }) => ({ organizationId: params.id }),
+ useCase: readOrganizationHomeWorkspaceActivity,
+ staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
+})
diff --git a/apps/sim/app/api/organizations/[id]/search/history/sources/route.ts b/apps/sim/app/api/organizations/[id]/search/history/sources/route.ts
new file mode 100644
index 00000000000..2cdf43717fd
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/search/history/sources/route.ts
@@ -0,0 +1,23 @@
+import { removeSearchHistorySourceContract } from '@/lib/api/contracts/knowledge/search-history'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ removeSearchHistorySource,
+ searchHistoryOperations,
+} from '@/lib/knowledge/application/search-history'
+
+export const DELETE = defineInternalJsonRoute({
+ contract: removeSearchHistorySourceContract,
+ auth: internalSessionAuth,
+ operation: searchHistoryOperations.removeSource,
+ rateLimit: internalRateLimits.user({ bucketName: 'search-history-write' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ parseOptions: { maxBodyBytes: 24 * 1024 },
+ mapInput: ({ params, body }) => ({ organizationId: params.id, url: body.url }),
+ useCase: removeSearchHistorySource,
+ staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
+})
diff --git a/apps/sim/app/api/organizations/[id]/sim-links/route.ts b/apps/sim/app/api/organizations/[id]/sim-links/route.ts
index a3a52a01780..42b95c4d17a 100644
--- a/apps/sim/app/api/organizations/[id]/sim-links/route.ts
+++ b/apps/sim/app/api/organizations/[id]/sim-links/route.ts
@@ -5,7 +5,7 @@ import {
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
-import { presentSimLink } from '@/lib/api/server/sim-link-presenters'
+import { presentSimLink, presentSimLinkPage } from '@/lib/api/server/sim-link-presenters'
import { createSimLink, listSimLinks } from '@/lib/sim-links/application/use-cases'
const policy = {
@@ -21,7 +21,7 @@ export const GET = defineInternalJsonRoute({
operation: listSimLinks.operation,
useCase: listSimLinks,
mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }),
- present: ({ links, nextCursor }) => ({ links: links.map(presentSimLink), nextCursor }),
+ present: presentSimLinkPage,
})
export const POST = defineInternalJsonRoute({
diff --git a/apps/sim/app/api/resume/poll/route.ts b/apps/sim/app/api/resume/poll/route.ts
index d16de246b13..aa8cb8fd115 100644
--- a/apps/sim/app/api/resume/poll/route.ts
+++ b/apps/sim/app/api/resume/poll/route.ts
@@ -2,7 +2,6 @@ import { db } from '@sim/db'
import { pausedExecutions } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
-import { generateShortId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import { and, asc, inArray, isNotNull, lte, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -11,6 +10,7 @@ import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { logFailureOnce } from '@/lib/core/errors/failure-log'
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
+import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import {
@@ -58,7 +58,7 @@ interface RowResult {
}
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateShortId()
+ const requestId = generateRequestId()
const authError = verifyCronAuth(request, 'Time-pause resume poll')
if (authError) return authError
diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts
index b810d183aa8..2aa958fe13d 100644
--- a/apps/sim/app/api/schedules/[id]/route.ts
+++ b/apps/sim/app/api/schedules/[id]/route.ts
@@ -35,12 +35,12 @@ async function fetchAndAuthorize(
.limit(1)
if (!schedule) {
- logger.warn(`[${requestId}] Schedule not found: ${scheduleId}`)
+ logger.warn(`Schedule not found: ${scheduleId}`, { requestId })
return NextResponse.json({ error: 'Schedule not found' }, { status: 404 })
}
if (!schedule.workflowId) {
- logger.warn(`[${requestId}] Schedule has no workflow: ${scheduleId}`)
+ logger.warn(`Schedule has no workflow: ${scheduleId}`, { requestId })
return NextResponse.json({ error: 'Schedule has no associated workflow' }, { status: 400 })
}
@@ -51,12 +51,12 @@ async function fetchAndAuthorize(
})
if (!authorization.workflow) {
- logger.warn(`[${requestId}] Workflow not found for schedule: ${scheduleId}`)
+ logger.warn(`Workflow not found for schedule: ${scheduleId}`, { requestId })
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
if (!authorization.allowed) {
- logger.warn(`[${requestId}] User not authorized to modify schedule: ${scheduleId}`)
+ logger.warn(`User not authorized to modify schedule: ${scheduleId}`, { requestId })
return NextResponse.json(
{ error: authorization.message || 'Not authorized to modify this schedule' },
{ status: authorization.status }
@@ -91,7 +91,7 @@ export const GET = withRouteHandler(
return NextResponse.json({ schedule: authResult.schedule })
} catch (error) {
- logger.error(`[${requestId}] Failed to get schedule`, { error })
+ logger.error('Failed to get schedule', { error })
return NextResponse.json({ error: 'Failed to get schedule' }, { status: 500 })
}
}
@@ -104,7 +104,7 @@ export const PUT = withRouteHandler(
try {
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized schedule update attempt`)
+ logger.warn('Unauthorized schedule update attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -136,7 +136,7 @@ export const PUT = withRouteHandler(
.set({ status: 'disabled', nextRunAt: null, lastQueuedAt: null, updatedAt: new Date() })
.where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt)))
- logger.info(`[${requestId}] Disabled schedule: ${scheduleId}`)
+ logger.info(`Disabled schedule: ${scheduleId}`)
recordAudit({
workspaceId,
@@ -165,13 +165,13 @@ export const PUT = withRouteHandler(
}
if (!schedule.cronExpression) {
- logger.error(`[${requestId}] Schedule has no cron expression: ${scheduleId}`)
+ logger.error(`Schedule has no cron expression: ${scheduleId}`)
return NextResponse.json({ error: 'Schedule has no cron expression' }, { status: 400 })
}
const cronResult = validateCronExpression(schedule.cronExpression, schedule.timezone || 'UTC')
if (!cronResult.isValid || !cronResult.nextRun) {
- logger.error(`[${requestId}] Invalid cron expression for schedule: ${scheduleId}`)
+ logger.error(`Invalid cron expression for schedule: ${scheduleId}`)
return NextResponse.json({ error: 'Schedule has invalid cron expression' }, { status: 400 })
}
@@ -183,7 +183,7 @@ export const PUT = withRouteHandler(
.set({ status: 'active', failedCount: 0, infraRetryCount: 0, updatedAt: now, nextRunAt })
.where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt)))
- logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`)
+ logger.info(`Reactivated schedule: ${scheduleId}`)
recordAudit({
workspaceId,
@@ -210,7 +210,7 @@ export const PUT = withRouteHandler(
return NextResponse.json({ error: error.message }, { status: error.status })
}
- logger.error(`[${requestId}] Error updating schedule`, error)
+ logger.error('Error updating schedule', error)
return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 })
}
}
@@ -225,7 +225,7 @@ export const DELETE = withRouteHandler(
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized schedule delete attempt`)
+ logger.warn('Unauthorized schedule delete attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -235,7 +235,7 @@ export const DELETE = withRouteHandler(
await db.delete(workflowSchedule).where(eq(workflowSchedule.id, scheduleId))
- logger.info(`[${requestId}] Deleted schedule: ${scheduleId}`)
+ logger.info(`Deleted schedule: ${scheduleId}`)
recordAudit({
workspaceId,
@@ -257,7 +257,7 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ message: 'Schedule deleted successfully' })
} catch (error) {
- logger.error(`[${requestId}] Error deleting schedule`, error)
+ logger.error('Error deleting schedule', error)
return NextResponse.json({ error: 'Failed to delete schedule' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts
index d92f4f68b45..bff1dbad8ec 100644
--- a/apps/sim/app/api/schedules/execute/route.ts
+++ b/apps/sim/app/api/schedules/execute/route.ts
@@ -363,12 +363,13 @@ async function restoreScheduleClaim(
)
.returning({ id: workflowSchedule.id })
.catch((error) => {
- logger.error(`[${requestId}] ${context}`, error)
+ logger.error(context, error, { requestId })
throw error
})
if (!restored) {
- logger.warn(`[${requestId}] ${context}`, {
+ logger.warn(context, {
+ requestId,
scheduleId,
currentClaim: currentClaim.toISOString(),
activeClaim: activeClaim.toISOString(),
@@ -743,7 +744,8 @@ async function reconcileExistingScheduleJob(params: {
}
if (!scheduleWorkflowId) {
- logger.warn(`[${requestId}] Cannot reconcile schedule job without a workflow`, {
+ logger.warn('Cannot reconcile schedule job without a workflow', {
+ requestId,
scheduleId: schedule.id,
jobId: job.id,
})
@@ -759,7 +761,8 @@ async function reconcileExistingScheduleJob(params: {
`Failed to restore schedule ${schedule.id} claim for recovery`
)
if (!restored) {
- logger.info(`[${requestId}] Skipped schedule reconciliation after claim changed`, {
+ logger.info('Skipped schedule reconciliation after claim changed', {
+ requestId,
scheduleId: schedule.id,
jobId: job.id,
})
@@ -819,7 +822,8 @@ async function deferClaimedScheduleAfterQueueFailure(
const retryDelayMs = calculateScheduleInfraRetryDelayMs(retryAttempt)
const nextRetryAt = new Date(now.getTime() + retryDelayMs)
- logger.warn(`[${requestId}] Deferring schedule after queue infrastructure failure`, {
+ logger.warn('Deferring schedule after queue infrastructure failure', {
+ requestId,
scheduleId: schedule.id,
workflowId: schedule.workflowId,
retryAttempt,
@@ -843,7 +847,7 @@ async function deferClaimedScheduleAfterQueueFailure(
)
)
.catch((updateError) => {
- logger.error(`[${requestId}] ${context}`, updateError)
+ logger.error(context, updateError, { requestId })
throw updateError
})
}
@@ -870,7 +874,8 @@ async function handleClaimedScheduleSetupFailure(
return
}
- logger.error(`[${requestId}] Non-retryable schedule setup failure`, {
+ logger.error('Non-retryable schedule setup failure', {
+ requestId,
scheduleId: schedule.id,
workflowId: schedule.workflowId,
error: toError(error).message,
@@ -1152,7 +1157,8 @@ async function executeDatabaseScheduleJob(
tryStartDatabaseScheduleJob(jobId)
)
if (startResult === 'not_pending') {
- logger.info(`[${requestId}] Database schedule execution job is no longer pending`, {
+ logger.info('Database schedule execution job is no longer pending', {
+ requestId,
scheduleId: schedule.id,
workflowId: schedule.workflowId,
jobId,
@@ -1161,7 +1167,8 @@ async function executeDatabaseScheduleJob(
}
if (startResult === 'capacity_full') {
- logger.info(`[${requestId}] Deferred database schedule execution because capacity is full`, {
+ logger.info('Deferred database schedule execution because capacity is full', {
+ requestId,
scheduleId: schedule.id,
workflowId: schedule.workflowId,
jobId,
@@ -1182,7 +1189,8 @@ async function executeDatabaseScheduleJob(
await jobQueue.completeJob(jobId, output ?? null)
} catch (error) {
const errorMessage = toError(error).message
- logger.error(`[${requestId}] Schedule execution failed for workflow ${schedule.workflowId}`, {
+ logger.error(`Schedule execution failed for workflow ${schedule.workflowId}`, {
+ requestId,
scheduleId: schedule.id,
jobId,
error: errorMessage,
@@ -1262,7 +1270,8 @@ async function resumePendingDatabaseScheduleJobs(
const claimState = await getScheduleClaimState(recoveryMetadata, claimedAt)
if (claimState === 'released') {
- logger.info(`[${requestId}] Cancelling stale pending schedule execution job`, {
+ logger.info('Cancelling stale pending schedule execution job', {
+ requestId,
scheduleId: recoveryMetadata.scheduleId,
workflowId: recoveryMetadata.workflowId,
jobId: job.id,
@@ -1270,7 +1279,8 @@ async function resumePendingDatabaseScheduleJobs(
return cancelReleasedPendingDatabaseScheduleCarrier(job.id)
}
if (claimState === 'claimed_by_other') {
- logger.info(`[${requestId}] Leaving pending schedule execution job for active claimant`, {
+ logger.info('Leaving pending schedule execution job for active claimant', {
+ requestId,
scheduleId: recoveryMetadata.scheduleId,
workflowId: recoveryMetadata.workflowId,
jobId: job.id,
@@ -1281,7 +1291,8 @@ async function resumePendingDatabaseScheduleJobs(
const payloadValidation = getSchedulePayloadValidation(job.payload)
if (!payloadValidation.success) {
const error = `Invalid pending schedule execution payload: ${payloadValidation.error}`
- logger.warn(`[${requestId}] Rejecting invalid pending schedule execution payload`, {
+ logger.warn('Rejecting invalid pending schedule execution payload', {
+ requestId,
scheduleId: recoveryMetadata.scheduleId,
workflowId: recoveryMetadata.workflowId,
jobId: job.id,
@@ -1299,7 +1310,8 @@ async function resumePendingDatabaseScheduleJobs(
return true
}
- logger.info(`[${requestId}] Resuming pending database schedule execution job`, {
+ logger.info('Resuming pending database schedule execution job', {
+ requestId,
scheduleId: recoveryMetadata.scheduleId,
workflowId: recoveryMetadata.workflowId,
jobId: job.id,
@@ -1326,7 +1338,8 @@ async function resumePendingDatabaseScheduleJobs(
}
if (result.status === 'rejected') {
- logger.error(`[${requestId}] Failed to resume pending database schedule execution job`, {
+ logger.error('Failed to resume pending database schedule execution job', {
+ requestId,
jobId: pendingJobs[index]?.id,
error: toError(result.reason).message,
})
@@ -1364,7 +1377,8 @@ async function processScheduleItem(
(existingJob.status === JOB_STATUS.PENDING && existingJob.attempts > 0))
) {
await recoverStaleDatabaseScheduleJobs(new Date())
- logger.info(`[${requestId}] Reconciled claimed database schedule execution jobs`, {
+ logger.info('Reconciled claimed database schedule execution jobs', {
+ requestId,
scheduleId: schedule.id,
jobId: scheduleJobId,
})
@@ -1380,7 +1394,8 @@ async function processScheduleItem(
activeJobClaim &&
isStaleScheduleClaim(activeJobClaim, existingJob)
) {
- logger.warn(`[${requestId}] Cancelling stale schedule execution job`, {
+ logger.warn('Cancelling stale schedule execution job', {
+ requestId,
scheduleId: schedule.id,
jobId: existingJob.id,
claimedAt: activeJobClaim.toISOString(),
@@ -1404,7 +1419,8 @@ async function processScheduleItem(
const payloadValidation = getSchedulePayloadValidation(databaseJob.payload)
if (!payloadValidation.success) {
const error = `Invalid pending schedule execution payload: ${payloadValidation.error}`
- logger.warn(`[${requestId}] Rejecting invalid pending schedule execution payload`, {
+ logger.warn('Rejecting invalid pending schedule execution payload', {
+ requestId,
scheduleId: schedule.id,
workflowId: schedule.workflowId,
jobId: scheduleJobId,
@@ -1423,7 +1439,8 @@ async function processScheduleItem(
return
}
- logger.info(`[${requestId}] Resuming pending database schedule execution job`, {
+ logger.info('Resuming pending database schedule execution job', {
+ requestId,
scheduleId: schedule.id,
jobId: scheduleJobId,
})
@@ -1455,7 +1472,8 @@ async function processScheduleItem(
databaseJob.status !== JOB_STATUS.PENDING &&
databaseJob.status !== JOB_STATUS.PROCESSING
) {
- logger.info(`[${requestId}] Database schedule execution job reached terminal state`, {
+ logger.info('Database schedule execution job reached terminal state', {
+ requestId,
scheduleId: schedule.id,
jobId: scheduleJobId,
status: databaseJob.status,
@@ -1471,7 +1489,8 @@ async function processScheduleItem(
return
}
- logger.info(`[${requestId}] Schedule execution job already exists`, {
+ logger.info('Schedule execution job already exists', {
+ requestId,
scheduleId: schedule.id,
jobId: scheduleJobId,
status: databaseJob?.status ?? existingJob.status,
@@ -1494,7 +1513,8 @@ async function processScheduleItem(
return
}
if (existingJob) {
- logger.info(`[${requestId}] Reconciling schedule claim for finished job`, {
+ logger.info('Reconciling schedule claim for finished job', {
+ requestId,
scheduleId: schedule.id,
jobId: scheduleJobId,
status: existingJob.status,
@@ -1581,9 +1601,9 @@ async function processScheduleItem(
const classifiedError = isAsyncJobEnqueueError(error) ? error : null
const acceptance = classifiedError?.acceptance ?? 'unknown'
logger.error(
- `[${requestId}] Failed to enqueue schedule execution for workflow ${schedule.workflowId}`,
+ `Failed to enqueue schedule execution for workflow ${schedule.workflowId}`,
error,
- { acceptance, jobId: scheduleJobId }
+ { requestId, acceptance, jobId: scheduleJobId }
)
/**
* The queue may have accepted the job before the response was lost, and
@@ -1603,12 +1623,13 @@ async function processScheduleItem(
return
}
- logger.info(
- `[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}`
- )
+ logger.info(`Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}`, {
+ requestId,
+ })
if (useDatabaseFallback) {
- logger.info(`[${requestId}] Executing durable database schedule execution job`, {
+ logger.info('Executing durable database schedule execution job', {
+ requestId,
scheduleId: schedule.id,
workflowId: schedule.workflowId,
jobId,
@@ -1629,7 +1650,8 @@ async function processScheduleItem(
const queuedJob = await jobQueue.getJob(jobId)
if (queuedJob && !['pending', 'processing'].includes(queuedJob.status)) {
- logger.info(`[${requestId}] Schedule execution job already finished`, {
+ logger.info('Schedule execution job already finished', {
+ requestId,
scheduleId: schedule.id,
jobId,
status: queuedJob.status,
@@ -1650,7 +1672,8 @@ async function processScheduleItem(
)
if (queuedJobClaim) {
if (isStaleScheduleClaim(queuedJobClaim, queuedJob)) {
- logger.warn(`[${requestId}] Cancelling stale queued schedule execution job`, {
+ logger.warn('Cancelling stale queued schedule execution job', {
+ requestId,
scheduleId: schedule.id,
jobId,
claimedAt: queuedJobClaim.toISOString(),
@@ -1676,7 +1699,8 @@ async function processScheduleItem(
}
}
- logger.info(`[${requestId}] Schedule execution task accepted`, {
+ logger.info('Schedule execution task accepted', {
+ requestId,
scheduleId: schedule.id,
workflowId: schedule.workflowId,
jobId,
@@ -1686,8 +1710,9 @@ async function processScheduleItem(
})
} catch (error) {
logger.error(
- `[${requestId}] Failed after queueing schedule execution for workflow ${schedule.workflowId}`,
- error
+ `Failed after queueing schedule execution for workflow ${schedule.workflowId}`,
+ error,
+ { requestId }
)
if (!enqueuedJobId && !carrierObservedOrLookupUncertain) {
await handleClaimedScheduleSetupFailure(
@@ -1701,7 +1726,8 @@ async function processScheduleItem(
return
}
- logger.warn(`[${requestId}] Preserved schedule occurrence after carrier uncertainty`, {
+ logger.warn('Preserved schedule occurrence after carrier uncertainty', {
+ requestId,
scheduleId: schedule.id,
jobId: enqueuedJobId ?? scheduleJobId,
error: toError(error).message,
@@ -1771,8 +1797,9 @@ export async function runScheduleTick(requestId: string): Promise {
const requestId = generateRequestId()
- logger.info(`[${requestId}] Scheduled execution triggered at ${new Date().toISOString()}`)
+ logger.info(`Scheduled execution triggered at ${new Date().toISOString()}`)
const authError = verifyCronAuth(request, 'Schedule execution')
if (authError) {
diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts
index f3dc25aa919..bae568dedec 100644
--- a/apps/sim/app/api/schedules/route.ts
+++ b/apps/sim/app/api/schedules/route.ts
@@ -32,7 +32,7 @@ export const GET = withRouteHandler(async (req: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized schedule query attempt`)
+ logger.warn('Unauthorized schedule query attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -64,7 +64,7 @@ export const GET = withRouteHandler(async (req: NextRequest) => {
)
}
- logger.info(`[${requestId}] Getting schedule for workflow ${workflowId}`)
+ logger.info(`Getting schedule for workflow ${workflowId}`)
const conditions = [eq(workflowSchedule.workflowId, workflowId)]
if (blockId) {
@@ -114,7 +114,7 @@ export const GET = withRouteHandler(async (req: NextRequest) => {
{ headers }
)
} catch (error) {
- logger.error(`[${requestId}] Error retrieving workflow schedule`, error)
+ logger.error('Error retrieving workflow schedule', error)
return NextResponse.json({ error: 'Failed to retrieve workflow schedule' }, { status: 500 })
}
})
@@ -125,7 +125,7 @@ async function handleWorkspaceSchedules(requestId: string, userId: string, works
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
- logger.info(`[${requestId}] Getting all schedules for workspace ${workspaceId}`)
+ logger.info(`Getting all schedules for workspace ${workspaceId}`, { requestId })
const workflowRows = await db
.select({
diff --git a/apps/sim/app/api/search/personal-credentials/route.ts b/apps/sim/app/api/search/personal-credentials/route.ts
new file mode 100644
index 00000000000..c7a37cf3a84
--- /dev/null
+++ b/apps/sim/app/api/search/personal-credentials/route.ts
@@ -0,0 +1,38 @@
+import {
+ removeSearchPersonalCredentialContract,
+ saveSearchPersonalCredentialContract,
+} from '@/lib/api/contracts/search-personal-credentials'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ internalCredentialErrorPolicy,
+ internalPersonalCredentialConnectionErrorPolicy,
+} from '@/lib/credentials/api/route-policies'
+import {
+ removeSearchPersonalCredential,
+ saveSearchPersonalCredential,
+} from '@/lib/credentials/application/search-personal-credentials'
+
+export const POST = defineInternalJsonRoute({
+ contract: saveSearchPersonalCredentialContract,
+ auth: internalSessionAuth,
+ operation: saveSearchPersonalCredential.operation,
+ rateLimit: internalRateLimits.user({ bucketName: 'search-personal-credentials' }),
+ errorPolicy: internalPersonalCredentialConnectionErrorPolicy,
+ parseOptions: { maxBodyBytes: 16_384 },
+ mapInput: ({ body }) => body,
+ useCase: saveSearchPersonalCredential,
+})
+
+export const DELETE = defineInternalJsonRoute({
+ contract: removeSearchPersonalCredentialContract,
+ auth: internalSessionAuth,
+ operation: removeSearchPersonalCredential.operation,
+ rateLimit: internalRateLimits.user({ bucketName: 'search-personal-credentials' }),
+ errorPolicy: internalCredentialErrorPolicy,
+ mapInput: ({ body }) => body,
+ useCase: removeSearchPersonalCredential,
+})
diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts
index 456ff2b8d1d..a9ec91c2635 100644
--- a/apps/sim/app/api/skills/route.ts
+++ b/apps/sim/app/api/skills/route.ts
@@ -13,7 +13,6 @@ import {
messageForOrchestrationError,
statusForOrchestrationError,
} from '@/lib/core/orchestration/types'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
@@ -71,12 +70,10 @@ const withReadOnly = (skills: T[]) =>
/** GET - Fetch all skills for a workspace */
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const principal = await authenticatePrincipal()
if (!principal) {
- logger.warn(`[${requestId}] Unauthorized skills access attempt`)
+ logger.warn('Unauthorized skills access attempt')
return unauthorized()
}
@@ -84,7 +81,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
Object.fromEntries(request.nextUrl.searchParams.entries())
)
if (!query.success) {
- logger.warn(`[${requestId}] Invalid skills query`, { errors: query.error.issues })
+ logger.warn('Invalid skills query', { errors: query.error.issues })
return NextResponse.json(
{ error: 'Invalid request data', details: query.error.issues },
{ status: 400 }
@@ -101,19 +98,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
} catch (error) {
const projected = orchestrationErrorResponse(error, 'Failed to fetch skills')
if (projected) return projected
- logger.error(`[${requestId}] Error fetching skills:`, error)
+ logger.error('Error fetching skills:', error)
return NextResponse.json({ error: 'Failed to fetch skills' }, { status: 500 })
}
})
/** POST - Create or update skills */
export const POST = withRouteHandler(async (req: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const principal = await authenticatePrincipal()
if (!principal) {
- logger.warn(`[${requestId}] Unauthorized skills update attempt`)
+ logger.warn('Unauthorized skills update attempt')
return unauthorized()
}
@@ -123,7 +118,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
{},
{
validationErrorResponse: (error) => {
- logger.warn(`[${requestId}] Invalid skills data`, { errors: error.issues })
+ logger.warn('Invalid skills data', { errors: error.issues })
return validationErrorResponse(error, 'Invalid request data')
},
}
@@ -164,22 +159,20 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
} catch (error) {
const projected = orchestrationErrorResponse(error, 'Failed to update skills')
if (projected) {
- logger.warn(`[${requestId}] Skill write rejected`, { status: projected.status })
+ logger.warn('Skill write rejected', { status: projected.status })
return projected
}
- logger.error(`[${requestId}] Error updating skills`, error)
+ logger.error('Error updating skills', error)
return NextResponse.json({ error: 'Failed to update skills' }, { status: 500 })
}
})
/** DELETE - Delete a skill by ID */
export const DELETE = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const principal = await authenticatePrincipal()
if (!principal) {
- logger.warn(`[${requestId}] Unauthorized skill deletion attempt`)
+ logger.warn('Unauthorized skill deletion attempt')
return unauthorized()
}
@@ -187,7 +180,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
Object.fromEntries(request.nextUrl.searchParams.entries())
)
if (!query.success) {
- logger.warn(`[${requestId}] Invalid skill deletion query`, { errors: query.error.issues })
+ logger.warn('Invalid skill deletion query', { errors: query.error.issues })
return NextResponse.json(
{ error: 'Invalid request data', details: query.error.issues },
{ status: 400 }
@@ -208,15 +201,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
{ groups: { workspace: workspaceId } }
)
- logger.info(`[${requestId}] Deleted skill: ${skillId}`)
+ logger.info(`Deleted skill: ${skillId}`)
return NextResponse.json({ success: true })
} catch (error) {
const projected = orchestrationErrorResponse(error, 'Failed to delete skill')
if (projected) {
- logger.warn(`[${requestId}] Skill delete rejected`, { status: projected.status })
+ logger.warn('Skill delete rejected', { status: projected.status })
return projected
}
- logger.error(`[${requestId}] Error deleting skill:`, error)
+ logger.error('Error deleting skill:', error)
return NextResponse.json({ error: 'Failed to delete skill' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts
index ecd2ef8e611..5aff52b2834 100644
--- a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts
+++ b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts
@@ -56,7 +56,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
excludeRowIds,
})
logger.info(
- `[${requestId}] cancel-runs: tableId=${tableId} scope=${scope}${
+ `cancel-runs: tableId=${tableId} scope=${scope}${
rowId ? ` rowId=${rowId}` : ''
} cancelled=${cancelled}`
)
@@ -74,7 +74,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
if (error instanceof TableQueryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
- logger.error(`[${requestId}] cancel-runs failed:`, error)
+ logger.error('cancel-runs failed:', error)
return NextResponse.json({ error: 'Failed to cancel runs' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts
index 57238d3acec..fb9a991d12c 100644
--- a/apps/sim/app/api/table/[tableId]/columns/route.ts
+++ b/apps/sim/app/api/table/[tableId]/columns/route.ts
@@ -37,7 +37,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
- logger.warn(`[${requestId}] Unauthorized column creation attempt`)
+ logger.warn('Unauthorized column creation attempt')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
@@ -84,7 +84,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
return NextResponse.json({ error: msg }, { status: 404 })
}
- logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error)
+ logger.error(`Error adding column to table ${tableId}:`, error)
return NextResponse.json({ error: 'Failed to add column' }, { status: 500 })
}
})
@@ -97,7 +97,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
- logger.warn(`[${requestId}] Unauthorized column update attempt`)
+ logger.warn('Unauthorized column update attempt')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
@@ -140,7 +140,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
return validationErrorResponse(error, 'Invalid request data')
}
- logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error)
+ logger.error(`Error updating column in table ${tableId}:`, error)
return NextResponse.json({ error: 'Failed to update column' }, { status: 500 })
}
})
@@ -154,7 +154,7 @@ export const DELETE = withRouteHandler(
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
- logger.warn(`[${requestId}] Unauthorized column deletion attempt`)
+ logger.warn('Unauthorized column deletion attempt')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
@@ -202,7 +202,7 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ error: msg }, { status: 400 })
}
- logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error)
+ logger.error(`Error deleting column from table ${tableId}:`, error)
return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.ts
index 4ac28aa8772..d5c4b38cabc 100644
--- a/apps/sim/app/api/table/[tableId]/delete-async/route.ts
+++ b/apps/sim/app/api/table/[tableId]/delete-async/route.ts
@@ -126,7 +126,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
throw error
}
- logger.info(`[${requestId}] Async row delete started`, {
+ logger.info('Async row delete started', {
tableId,
jobId,
hasFilter: Boolean(filter),
diff --git a/apps/sim/app/api/table/[tableId]/dispatches/route.ts b/apps/sim/app/api/table/[tableId]/dispatches/route.ts
index 2043f2c4e4e..b086d19e1e9 100644
--- a/apps/sim/app/api/table/[tableId]/dispatches/route.ts
+++ b/apps/sim/app/api/table/[tableId]/dispatches/route.ts
@@ -63,7 +63,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
},
})
} catch (error) {
- logger.error(`[${requestId}] list-dispatches failed:`, error)
+ logger.error('list-dispatches failed:', error)
return NextResponse.json({ error: 'Failed to list active dispatches' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts
index 04e4915a632..6e5d10c9eef 100644
--- a/apps/sim/app/api/table/[tableId]/import/route.ts
+++ b/apps/sim/app/api/table/[tableId]/import/route.ts
@@ -105,7 +105,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
if (table.workspaceId !== workspaceId) {
logger.warn(
- `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${workspaceId}, Actual: ${table.workspaceId}`
+ `Workspace ID mismatch for table ${tableId}. Provided: ${workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
@@ -195,7 +195,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
} catch (error) {
if (isMultipartError(error)) return multipartErrorResponse(error)
- logger.error(`[${requestId}] CSV import into existing table failed:`, error)
+ logger.error('CSV import into existing table failed:', error)
return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 })
} finally {
fileStream?.destroy()
diff --git a/apps/sim/app/api/table/[tableId]/metadata/route.ts b/apps/sim/app/api/table/[tableId]/metadata/route.ts
index e455ec187e9..98feb44e0b2 100644
--- a/apps/sim/app/api/table/[tableId]/metadata/route.ts
+++ b/apps/sim/app/api/table/[tableId]/metadata/route.ts
@@ -23,7 +23,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
- logger.warn(`[${requestId}] Unauthorized metadata update attempt`)
+ logger.warn('Unauthorized metadata update attempt')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
@@ -61,7 +61,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR
return NextResponse.json({ success: true, data: { metadata: updated } })
} catch (error) {
- logger.error(`[${requestId}] Error updating table metadata:`, error)
+ logger.error('Error updating table metadata:', error)
return NextResponse.json({ error: 'Failed to update metadata' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts
index 1d2f3e726c6..7356259e071 100644
--- a/apps/sim/app/api/table/[tableId]/restore/route.ts
+++ b/apps/sim/app/api/table/[tableId]/restore/route.ts
@@ -47,14 +47,14 @@ export const POST = withRouteHandler(
return orchestrationOutcomeErrorResponse(result, 'Failed to restore table')
}
- logger.info(`[${requestId}] Restored table ${tableId}`)
+ logger.info(`Restored table ${tableId}`)
return NextResponse.json({
success: true,
data: { table: result.table },
})
} catch (error) {
- logger.error(`[${requestId}] Error restoring table ${tableId}`, error)
+ logger.error(`Error restoring table ${tableId}`, error)
return NextResponse.json({ error: 'Failed to restore table' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts
index e868b2e3f6d..48d74e04a7a 100644
--- a/apps/sim/app/api/table/[tableId]/route.ts
+++ b/apps/sim/app/api/table/[tableId]/route.ts
@@ -96,7 +96,7 @@ export const PATCH = withRouteHandler(
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
- logger.warn(`[${requestId}] Unauthorized table rename attempt`)
+ logger.warn('Unauthorized table rename attempt')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
@@ -214,7 +214,7 @@ export const PATCH = withRouteHandler(
return NextResponse.json({ error: error.message }, { status: 409 })
}
- logger.error(`[${requestId}] Error updating table:`, error)
+ logger.error('Error updating table:', error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to update table') },
{ status: 500 }
@@ -232,7 +232,7 @@ export const DELETE = withRouteHandler(
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
- logger.warn(`[${requestId}] Unauthorized table delete attempt`)
+ logger.warn('Unauthorized table delete attempt')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
@@ -277,7 +277,7 @@ export const DELETE = withRouteHandler(
return validationErrorResponse(error)
}
- logger.error(`[${requestId}] Error deleting table:`, error)
+ logger.error('Error deleting table:', error)
return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/table/[tableId]/rows/find/route.ts b/apps/sim/app/api/table/[tableId]/rows/find/route.ts
index de7b8034a0b..5721b9667e4 100644
--- a/apps/sim/app/api/table/[tableId]/rows/find/route.ts
+++ b/apps/sim/app/api/table/[tableId]/rows/find/route.ts
@@ -58,7 +58,7 @@ export const GET = withRouteHandler(
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
- `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
+ `Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
@@ -93,7 +93,7 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: error.message }, { status: 400 })
}
- logger.error(`[${requestId}] Error finding rows:`, error)
+ logger.error('Error finding rows:', error)
return NextResponse.json({ error: 'Failed to find rows' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts
index 034d8059296..7ddf1f1a7ff 100644
--- a/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts
+++ b/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts
@@ -64,7 +64,7 @@ export const PATCH = withRouteHandler(
if (error instanceof TableViewValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
- logger.error(`[${requestId}] Error updating table view:`, error)
+ logger.error('Error updating table view:', error)
return NextResponse.json({ error: 'Failed to update view' }, { status: 500 })
}
}
@@ -110,7 +110,7 @@ export const DELETE = withRouteHandler(
if (error instanceof TableViewValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
- logger.error(`[${requestId}] Error deleting table view:`, error)
+ logger.error('Error deleting table view:', error)
return NextResponse.json({ error: 'Failed to delete view' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/table/[tableId]/views/route.ts b/apps/sim/app/api/table/[tableId]/views/route.ts
index b191b9bf55f..df62afde72e 100644
--- a/apps/sim/app/api/table/[tableId]/views/route.ts
+++ b/apps/sim/app/api/table/[tableId]/views/route.ts
@@ -45,7 +45,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR
return NextResponse.json({ success: true, data: { views } })
} catch (error) {
- logger.error(`[${requestId}] Error listing table views:`, error)
+ logger.error('Error listing table views:', error)
return NextResponse.json({ error: 'Failed to list views' }, { status: 500 })
}
})
@@ -90,7 +90,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Table
if (error instanceof TableViewValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
- logger.error(`[${requestId}] Error creating table view:`, error)
+ logger.error('Error creating table view:', error)
return NextResponse.json({ error: 'Failed to create view' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts
index f3b03e66baa..056eebc7cbd 100644
--- a/apps/sim/app/api/table/import-csv/route.ts
+++ b/apps/sim/app/api/table/import-csv/route.ts
@@ -162,7 +162,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
} catch (error) {
if (isMultipartError(error)) return multipartErrorResponse(error)
- logger.error(`[${requestId}] CSV import failed:`, error)
+ logger.error('CSV import failed:', error)
return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 })
} finally {
fileStream?.destroy()
diff --git a/apps/sim/app/api/table/jobs/route.ts b/apps/sim/app/api/table/jobs/route.ts
index f2225b308cd..ad7fd93dcdb 100644
--- a/apps/sim/app/api/table/jobs/route.ts
+++ b/apps/sim/app/api/table/jobs/route.ts
@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { listTableJobsContract } from '@/lib/api/contracts/tables'
import { parseRequest } from '@/lib/api/server'
import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions'
import { listWorkspaceExportJobs } from '@/lib/table/jobs/service'
@@ -21,8 +20,6 @@ export const dynamic = 'force-dynamic'
* excluded from the table-level job derivation, so the tray reads them here.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
@@ -58,6 +55,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
}
const jobs = await listWorkspaceExportJobs(workspaceId)
- logger.info(`[${requestId}] Listed ${jobs.length} export jobs`, { workspaceId })
+ logger.info(`Listed ${jobs.length} export jobs`, { workspaceId })
return NextResponse.json({ success: true, data: { jobs } })
})
diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts
index 626cc311ace..ca8f1aadb92 100644
--- a/apps/sim/app/api/table/utils.ts
+++ b/apps/sim/app/api/table/utils.ts
@@ -347,14 +347,14 @@ export function accessError(
context?: string
): NextResponse {
if (result.capability) {
- logger.warn(
- `[${requestId}] ${capabilityRefusal(result.capability)}${context ? `: ${context}` : ''}`
- )
+ logger.warn(`${capabilityRefusal(result.capability)}${context ? `: ${context}` : ''}`, {
+ requestId,
+ })
return capabilityRefusalResponse(result.capability)
}
const message = result.status === 404 ? 'Table not found' : 'Access denied'
- logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
+ logger.warn(`${message}${context ? `: ${context}` : ''}`, { requestId })
return NextResponse.json({ error: message }, { status: result.status })
}
diff --git a/apps/sim/app/api/tools/custom/route.ts b/apps/sim/app/api/tools/custom/route.ts
index a15706753cd..baf4b307621 100644
--- a/apps/sim/app/api/tools/custom/route.ts
+++ b/apps/sim/app/api/tools/custom/route.ts
@@ -22,12 +22,10 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CustomToolsAPI')
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
- logger.warn(`[${requestId}] Unauthorized custom tools access attempt`)
+ logger.warn('Unauthorized custom tools access attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -47,7 +45,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
action: 'read',
})
if (!workflowAuthorization.allowed) {
- logger.warn(`[${requestId}] Workflow authorization failed for custom tools`, {
+ logger.warn('Workflow authorization failed for custom tools', {
workflowId,
userId,
status: workflowAuthorization.status,
@@ -69,9 +67,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
resolvedWorkspaceId
)
if (!userPermission) {
- logger.warn(
- `[${requestId}] User ${userId} does not have access to workspace ${resolvedWorkspaceId}`
- )
+ logger.warn(`User ${userId} does not have access to workspace ${resolvedWorkspaceId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
}
@@ -92,7 +88,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ data: result }, { status: 200 })
} catch (error) {
- logger.error(`[${requestId}] Error fetching custom tools:`, error)
+ logger.error('Error fetching custom tools:', error)
return NextResponse.json({ error: 'Failed to fetch custom tools' }, { status: 500 })
}
})
@@ -103,7 +99,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
try {
const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
- logger.warn(`[${requestId}] Unauthorized custom tools update attempt`)
+ logger.warn('Unauthorized custom tools update attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -114,7 +110,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
{
invalidJson: 'throw',
validationErrorResponse: (error) => {
- logger.warn(`[${requestId}] Invalid custom tools data`, { errors: error.issues })
+ logger.warn('Invalid custom tools data', { errors: error.issues })
return NextResponse.json(
{
error: 'Invalid request data',
@@ -131,20 +127,18 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const { tools, workspaceId, source } = parsed.data.body
if (!workspaceId) {
- logger.warn(`[${requestId}] Missing workspaceId in request body`)
+ logger.warn('Missing workspaceId in request body')
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!userPermission) {
- logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`)
+ logger.warn(`User ${userId} does not have access to workspace ${workspaceId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
if (userPermission !== 'admin' && userPermission !== 'write') {
- logger.warn(
- `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`
- )
+ logger.warn(`User ${userId} does not have write permission for workspace ${workspaceId}`)
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
@@ -182,21 +176,20 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
return NextResponse.json({ success: true, data: resultTools })
} catch (error) {
- logger.error(`[${requestId}] Error updating custom tools`, error)
+ logger.error('Error updating custom tools', error)
const errorMessage = getErrorMessage(error, 'Failed to update custom tools')
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
})
export const DELETE = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
const parsed = await parseRequest(
deleteCustomToolContract,
request,
{},
{
validationErrorResponse: (error) => {
- logger.warn(`[${requestId}] Missing tool ID for deletion`)
+ logger.warn('Missing tool ID for deletion')
return NextResponse.json(
{
error: 'Tool ID is required',
@@ -214,7 +207,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
- logger.warn(`[${requestId}] Unauthorized custom tool deletion attempt`)
+ logger.warn('Unauthorized custom tool deletion attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -227,7 +220,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
.limit(1)
if (existingTool.length === 0) {
- logger.warn(`[${requestId}] Tool not found: ${toolId}`)
+ logger.warn(`Tool not found: ${toolId}`)
return NextResponse.json({ error: 'Tool not found' }, { status: 404 })
}
@@ -235,33 +228,27 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
if (tool.workspaceId) {
if (!workspaceId) {
- logger.warn(`[${requestId}] Missing workspaceId for workspace-scoped tool`)
+ logger.warn('Missing workspaceId for workspace-scoped tool')
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!userPermission) {
- logger.warn(
- `[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`
- )
+ logger.warn(`User ${userId} does not have access to workspace ${workspaceId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
if (userPermission !== 'admin' && userPermission !== 'write') {
- logger.warn(
- `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`
- )
+ logger.warn(`User ${userId} does not have write permission for workspace ${workspaceId}`)
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
if (tool.workspaceId !== workspaceId) {
- logger.warn(`[${requestId}] Tool ${toolId} does not belong to workspace ${workspaceId}`)
+ logger.warn(`Tool ${toolId} does not belong to workspace ${workspaceId}`)
return NextResponse.json({ error: 'Tool not found' }, { status: 404 })
}
} else if (tool.userId !== userId) {
- logger.warn(
- `[${requestId}] User ${userId} attempted to delete tool they don't own: ${toolId}`
- )
+ logger.warn(`User ${userId} attempted to delete tool they don't own: ${toolId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
@@ -288,10 +275,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
metadata: { source },
})
- logger.info(`[${requestId}] Deleted tool: ${toolId}`)
+ logger.info(`Deleted tool: ${toolId}`)
return NextResponse.json({ success: true })
} catch (error) {
- logger.error(`[${requestId}] Error deleting custom tool:`, error)
+ logger.error('Error deleting custom tool:', error)
return NextResponse.json({ error: 'Failed to delete custom tool' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/users/me/settings/unsubscribe/route.ts b/apps/sim/app/api/users/me/settings/unsubscribe/route.ts
index 654324e85d4..259fd5b3e82 100644
--- a/apps/sim/app/api/users/me/settings/unsubscribe/route.ts
+++ b/apps/sim/app/api/users/me/settings/unsubscribe/route.ts
@@ -7,7 +7,6 @@ import {
} from '@/lib/api/contracts/user'
import { parseRequest } from '@/lib/api/server'
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { EmailType } from '@/lib/messaging/email/mailer'
import {
@@ -27,8 +26,6 @@ const UNSUBSCRIBE_RATE_LIMIT = {
}
export const GET = withRouteHandler(async (req: NextRequest) => {
- const requestId = generateRequestId()
-
const rateLimited = await enforceIpRateLimit('unsubscribe', req, UNSUBSCRIBE_RATE_LIMIT)
if (rateLimited) return rateLimited
@@ -43,14 +40,14 @@ export const GET = withRouteHandler(async (req: NextRequest) => {
}
)
if (!parsed.success) {
- logger.warn(`[${requestId}] Missing email or token in GET request`)
+ logger.warn('Missing email or token in GET request')
return parsed.response
}
const { email, token } = parsed.data.query
const tokenVerification = verifyUnsubscribeToken(email, token)
if (!tokenVerification.valid) {
- logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
+ logger.warn(`Invalid unsubscribe token for email: ${email}`)
return NextResponse.json({ error: 'Invalid or expired unsubscribe link' }, { status: 400 })
}
@@ -59,9 +56,7 @@ export const GET = withRouteHandler(async (req: NextRequest) => {
const preferences = await getEmailPreferences(email)
- logger.info(
- `[${requestId}] Valid unsubscribe GET request for email: ${email}, type: ${emailType}`
- )
+ logger.info(`Valid unsubscribe GET request for email: ${email}, type: ${emailType}`)
return NextResponse.json({
success: true,
@@ -72,14 +67,12 @@ export const GET = withRouteHandler(async (req: NextRequest) => {
currentPreferences: preferences || {},
})
} catch (error) {
- logger.error(`[${requestId}] Error processing unsubscribe GET request:`, error)
+ logger.error('Error processing unsubscribe GET request:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
export const POST = withRouteHandler(async (req: NextRequest) => {
- const requestId = generateRequestId()
-
const rateLimited = await enforceIpRateLimit('unsubscribe', req, UNSUBSCRIBE_RATE_LIMIT)
if (rateLimited) return rateLimited
@@ -101,14 +94,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
}
)
if (!parsed.success) {
- logger.warn(`[${requestId}] One-click unsubscribe missing email or token in URL`)
+ logger.warn('One-click unsubscribe missing email or token in URL')
return parsed.response
}
email = parsed.data.query.email
token = parsed.data.query.token
- logger.info(`[${requestId}] Processing one-click unsubscribe for: ${email}`)
+ logger.info(`Processing one-click unsubscribe for: ${email}`)
} else {
const parsed = await parseRequest(
unsubscribePostContract,
@@ -123,7 +116,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
}
)
if (!parsed.success) {
- logger.warn(`[${requestId}] Invalid unsubscribe POST data`)
+ logger.warn('Invalid unsubscribe POST data')
return parsed.response
}
@@ -134,7 +127,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const tokenVerification = verifyUnsubscribeToken(email, token)
if (!tokenVerification.valid) {
- logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
+ logger.warn(`Invalid unsubscribe token for email: ${email}`)
return NextResponse.json({ error: 'Invalid or expired unsubscribe link' }, { status: 400 })
}
@@ -142,7 +135,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const isTransactional = isTransactionalEmail(emailType)
if (isTransactional) {
- logger.warn(`[${requestId}] Attempted to unsubscribe from transactional email: ${email}`)
+ logger.warn(`Attempted to unsubscribe from transactional email: ${email}`)
return NextResponse.json(
{
error: 'Cannot unsubscribe from transactional emails',
@@ -171,11 +164,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
}
if (!success) {
- logger.error(`[${requestId}] Failed to update unsubscribe preferences for: ${email}`)
+ logger.error(`Failed to update unsubscribe preferences for: ${email}`)
return NextResponse.json({ error: 'Failed to process unsubscribe request' }, { status: 500 })
}
- logger.info(`[${requestId}] Successfully unsubscribed ${email} from ${type}`)
+ logger.info(`Successfully unsubscribed ${email} from ${type}`)
return NextResponse.json(
{
@@ -188,7 +181,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
{ status: 200 }
)
} catch (error) {
- logger.error(`[${requestId}] Error processing unsubscribe POST request:`, error)
+ logger.error('Error processing unsubscribe POST request:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts
index fb2cfa6b847..0624329b233 100644
--- a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts
+++ b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts
@@ -65,7 +65,7 @@ export const POST = withRouteHandler(
const isDeployed = Boolean(result.activeDeployment)
const attemptActivated = result.latestDeploymentAttempt?.status === 'active'
logger.info(
- `[${requestId}] Admin API: Deployment ${attemptActivated ? 'activated' : 'accepted'} for workflow ${workflowId}`
+ `Admin API: Deployment ${attemptActivated ? 'activated' : 'accepted'} for workflow ${workflowId}`
)
const response: AdminV1DeployResult = {
diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts
index acc3c49884c..7591a85e05d 100644
--- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts
+++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts
@@ -51,7 +51,7 @@ export const POST = withRouteHandler(
}
logger.info(
- `[${requestId}] Admin API: ${result.latestDeploymentAttempt?.status === 'active' ? 'Activated' : 'Accepted activation for'} version ${versionNum} on workflow ${workflowId}`
+ `Admin API: ${result.latestDeploymentAttempt?.status === 'active' ? 'Activated' : 'Accepted activation for'} version ${versionNum} on workflow ${workflowId}`
)
return singleResponse({
@@ -63,10 +63,7 @@ export const POST = withRouteHandler(
latestDeploymentAttempt: result.latestDeploymentAttempt,
})
} catch (error) {
- logger.error(
- `[${requestId}] Admin API: Failed to activate version for workflow ${workflowId}`,
- { error }
- )
+ logger.error(`Admin API: Failed to activate version for workflow ${workflowId}`, { error })
return adminInternalErrorResponse('Failed to activate deployment version')
}
})
diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts
index 2cfc9b27606..9c575bfb83e 100644
--- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts
+++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts
@@ -15,7 +15,6 @@ import { db } from '@sim/db'
import { auditLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v1GetAuditLogContract } from '@/lib/api/contracts/v1/audit-logs'
@@ -41,8 +40,6 @@ export const revalidate = 0
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateId().slice(0, 8)
-
try {
const rateLimit = await checkRateLimit(request, 'audit-logs')
if (!rateLimit.allowed) {
@@ -89,7 +86,7 @@ export const GET = withRouteHandler(
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
- logger.error(`[${requestId}] Audit log detail fetch error`, { error: message })
+ logger.error('Audit log detail fetch error', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts
index 187e3a83b1e..b92911c4c05 100644
--- a/apps/sim/app/api/v1/audit-logs/route.ts
+++ b/apps/sim/app/api/v1/audit-logs/route.ts
@@ -21,7 +21,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { v1ListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs'
import { parseRequest } from '@/lib/api/server'
@@ -50,8 +49,6 @@ export const revalidate = 0
* is nothing here for a group to withhold.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateId().slice(0, 8)
-
try {
const rateLimit = await checkRateLimit(request, 'audit-logs')
if (!rateLimit.allowed) {
@@ -117,7 +114,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
- logger.error(`[${requestId}] Audit logs fetch error`, { error: message })
+ logger.error('Audit logs fetch error', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts
index 7b79984c1fc..bc1994d5d52 100644
--- a/apps/sim/app/api/v1/files/[fileId]/route.ts
+++ b/apps/sim/app/api/v1/files/[fileId]/route.ts
@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { v1DeleteFileContract, v1DownloadFileContract } from '@/lib/api/contracts/v1/files'
import { parseRequest } from '@/lib/api/server'
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
@@ -37,8 +36,6 @@ interface FileRouteParams {
* capability twice.
*/
export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => {
- const requestId = generateRequestId()
-
try {
const rateLimit = await checkRateLimit(request, 'file-detail')
if (!rateLimit.allowed) {
@@ -91,15 +88,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo
{ status: statusForOrchestrationError(orchestrationError.code) }
)
}
- logger.error(`[${requestId}] Error downloading file:`, error)
+ logger.error('Error downloading file:', error)
return NextResponse.json({ error: 'Failed to download file' }, { status: 500 })
}
})
/** DELETE /api/v1/files/[fileId] — Archive a file. */
export const DELETE = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => {
- const requestId = generateRequestId()
-
try {
const rateLimit = await checkRateLimit(request, 'file-detail')
if (!rateLimit.allowed) {
@@ -138,9 +133,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil
return NextResponse.json({ error: result.error }, { status: 500 })
}
- logger.info(
- `[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}`
- )
+ logger.info(`Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}`)
return NextResponse.json({
success: true,
@@ -149,7 +142,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil
},
})
} catch (error) {
- logger.error(`[${requestId}] Error deleting file:`, error)
+ logger.error('Error deleting file:', error)
return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts
index 249694ef5b3..462eb24ec05 100644
--- a/apps/sim/app/api/v1/files/route.ts
+++ b/apps/sim/app/api/v1/files/route.ts
@@ -4,7 +4,6 @@ import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { v1ListFilesContract, v1UploadFileFormFieldsSchema } from '@/lib/api/contracts/v1/files'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
-import { generateRequestId } from '@/lib/core/utils/request'
import {
isMultipartFieldValidationError,
isPayloadSizeLimitError,
@@ -37,8 +36,6 @@ const MAX_FILE_SIZE = 100 * 1024 * 1024
/** GET /api/v1/files — List all files in a workspace. */
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const rateLimit = await checkRateLimit(request, 'files')
if (!rateLimit.allowed) {
@@ -80,15 +77,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
},
})
} catch (error) {
- logger.error(`[${requestId}] Error listing files:`, error)
+ logger.error('Error listing files:', error)
return NextResponse.json({ error: 'Failed to list files' }, { status: 500 })
}
})
/** POST /api/v1/files — Upload a file to a workspace. */
export const POST = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const rateLimit = await checkRateLimit(request, 'files')
if (!rateLimit.allowed) {
@@ -169,7 +164,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ secretProvenance: EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE }
)
- logger.info(`[${requestId}] Uploaded file: ${file.name} to workspace ${workspaceId}`)
+ logger.info(`Uploaded file: ${file.name} to workspace ${workspaceId}`)
recordAudit({
workspaceId,
@@ -219,7 +214,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: errorMessage }, { status: 409 })
}
- logger.error(`[${requestId}] Error uploading file:`, error)
+ logger.error('Error uploading file:', error)
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/v1/knowledge/utils.ts b/apps/sim/app/api/v1/knowledge/utils.ts
index 4cf6e70777e..abeb501818c 100644
--- a/apps/sim/app/api/v1/knowledge/utils.ts
+++ b/apps/sim/app/api/v1/knowledge/utils.ts
@@ -122,6 +122,6 @@ export function handleError(
}
}
- logger.error(`[${requestId}] ${defaultMessage}:`, error)
+ logger.error(`${defaultMessage}:`, error, { requestId })
return NextResponse.json({ error: defaultMessage }, { status: 500 })
}
diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts
index 1b384fc1639..3a33c4139e2 100644
--- a/apps/sim/app/api/v1/logs/[id]/route.ts
+++ b/apps/sim/app/api/v1/logs/[id]/route.ts
@@ -1,5 +1,4 @@
import { createLogger } from '@sim/logger'
-import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { v1GetLogContract } from '@/lib/api/contracts/v1/logs'
import { parseRequest } from '@/lib/api/server'
@@ -26,8 +25,6 @@ export const revalidate = 0
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateId().slice(0, 8)
-
try {
const rateLimit = await checkRateLimit(request, 'logs-detail')
if (!rateLimit.allowed) {
@@ -109,7 +106,7 @@ export const GET = withRouteHandler(
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
} catch (error: any) {
- logger.error(`[${requestId}] Log details fetch error`, { error: error.message })
+ logger.error('Log details fetch error', { error: error.message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts
index d43b44d750f..b3e4b2e2227 100644
--- a/apps/sim/app/api/v1/logs/route.ts
+++ b/apps/sim/app/api/v1/logs/route.ts
@@ -1,5 +1,4 @@
import { createLogger } from '@sim/logger'
-import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { v1ListLogsContract } from '@/lib/api/contracts/v1/logs'
import { parseRequest } from '@/lib/api/server'
@@ -30,8 +29,6 @@ export const dynamic = 'force-dynamic'
export const revalidate = 0
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateId().slice(0, 8)
-
try {
const rateLimit = await checkRateLimit(request, 'logs')
if (!rateLimit.allowed) {
@@ -91,7 +88,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return capabilityRefusalResponse(error.capability)
}
- logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, {
+ logger.info(`Fetching logs for workspace ${params.workspaceId}`, {
userId,
filters: {
workflowIds: params.workflowIds,
@@ -209,7 +206,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: any) {
- logger.error(`[${requestId}] Logs fetch error`, { error: error.message })
+ logger.error('Logs fetch error', { error: error.message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts
index 2037c566048..2fef238d238 100644
--- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts
+++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts
@@ -99,7 +99,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
const classified = orchestrationErrorResponse(error)
if (classified) return classified
- logger.error(`[${requestId}] Error adding column to table:`, error)
+ logger.error('Error adding column to table:', error)
return NextResponse.json({ error: 'Failed to add column' }, { status: 500 })
}
})
@@ -160,7 +160,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
const validationResponse = v1ValidationErrorResponseFromError(error)
if (validationResponse) return validationResponse
- logger.error(`[${requestId}] Error updating column in table:`, error)
+ logger.error('Error updating column in table:', error)
return NextResponse.json({ error: 'Failed to update column' }, { status: 500 })
}
})
@@ -230,7 +230,7 @@ export const DELETE = withRouteHandler(
const classified = orchestrationErrorResponse(error)
if (classified) return classified
- logger.error(`[${requestId}] Error deleting column from table:`, error)
+ logger.error('Error deleting column from table:', error)
return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts
index c9dcd9daae3..da191b2f71f 100644
--- a/apps/sim/app/api/v1/tables/[tableId]/route.ts
+++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts
@@ -97,7 +97,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR
},
})
} catch (error) {
- logger.error(`[${requestId}] Error getting table:`, error)
+ logger.error('Error getting table:', error)
return NextResponse.json({ error: 'Failed to get table' }, { status: 500 })
}
})
@@ -172,7 +172,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
- logger.error(`[${requestId}] Error deleting table:`, error)
+ logger.error('Error deleting table:', error)
return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
index 40ca9f09ade..e2a7090c016 100644
--- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
+++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
@@ -111,7 +111,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou
},
})
} catch (error) {
- logger.error(`[${requestId}] Error getting row:`, error)
+ logger.error('Error getting row:', error)
return NextResponse.json({ error: 'Failed to get row' }, { status: 500 })
}
})
@@ -204,7 +204,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
const classified = orchestrationErrorResponse(error)
if (classified) return classified
- logger.error(`[${requestId}] Error updating row:`, error)
+ logger.error('Error updating row:', error)
return NextResponse.json({ error: 'Failed to update row' }, { status: 500 })
}
})
@@ -257,7 +257,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row
if (lockError) return lockError
const classified = orchestrationErrorResponse(error)
if (classified) return classified
- logger.error(`[${requestId}] Error deleting row:`, error)
+ logger.error('Error deleting row:', error)
return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
index 3a497fdb39c..b626d602dc3 100644
--- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
+++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
@@ -122,7 +122,7 @@ async function handleBatchInsert(
const response = orchestrationErrorResponse(error)
if (response) return response
- logger.error(`[${requestId}] Error batch inserting rows:`, error)
+ logger.error('Error batch inserting rows:', error, { requestId })
return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 })
}
}
@@ -217,7 +217,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR
return NextResponse.json({ error: error.message }, { status: 400 })
}
- logger.error(`[${requestId}] Error querying rows:`, error)
+ logger.error('Error querying rows:', error)
return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 })
}
})
@@ -318,7 +318,7 @@ export const POST = withRouteHandler(
const response = orchestrationErrorResponse(error)
if (response) return response
- logger.error(`[${requestId}] Error inserting row:`, error)
+ logger.error('Error inserting row:', error)
return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 })
}
}
@@ -413,7 +413,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR
const response = orchestrationErrorResponse(error)
if (response) return response
- logger.error(`[${requestId}] Error updating rows by filter:`, error)
+ logger.error('Error updating rows by filter:', error)
return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 })
}
})
@@ -507,7 +507,7 @@ export const DELETE = withRouteHandler(
const response = orchestrationErrorResponse(error)
if (response) return response
- logger.error(`[${requestId}] Error deleting rows:`, error)
+ logger.error('Error deleting rows:', error)
return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts
index 503a072a35a..232f3e3f730 100644
--- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts
+++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts
@@ -116,7 +116,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser
const classified = orchestrationErrorResponse(error)
if (classified) return classified
- logger.error(`[${requestId}] Error upserting row:`, error)
+ logger.error('Error upserting row:', error)
return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts
index 5817317f4af..8502268ea97 100644
--- a/apps/sim/app/api/v1/tables/route.ts
+++ b/apps/sim/app/api/v1/tables/route.ts
@@ -29,8 +29,6 @@ export const revalidate = 0
/** GET /api/v1/tables — List all tables in a workspace. */
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const rateLimit = await checkRateLimit(request, 'tables')
if (!rateLimit.allowed) {
@@ -83,7 +81,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const validationResponse = v1ValidationErrorResponseFromError(error)
if (validationResponse) return validationResponse
- logger.error(`[${requestId}] Error listing tables:`, error)
+ logger.error('Error listing tables:', error)
return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 })
}
})
@@ -186,7 +184,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const classified = orchestrationErrorResponse(error)
if (classified) return classified
- logger.error(`[${requestId}] Error creating table:`, error)
+ logger.error('Error creating table:', error)
return NextResponse.json({ error: 'Failed to create table' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts
index a5c1a798391..61cf2671a44 100644
--- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts
+++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts
@@ -58,7 +58,7 @@ export const POST = withRouteHandler(
await assertWorkflowMutable(id)
- logger.info(`[${requestId}] Deploying workflow ${id} via v1 API`, { userId })
+ logger.info(`Deploying workflow ${id} via v1 API`, { userId })
const result = await performFullDeploy({
workflowId: id,
@@ -100,7 +100,7 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: error.message }, { status: error.status })
}
const message = getErrorMessage(error, 'Unknown error')
- logger.error(`[${requestId}] Workflow deploy error`, { error: message })
+ logger.error('Workflow deploy error', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
@@ -135,7 +135,7 @@ export const DELETE = withRouteHandler(
await assertWorkflowMutable(id)
- logger.info(`[${requestId}] Undeploying workflow ${id} via v1 API`, { userId })
+ logger.info(`Undeploying workflow ${id} via v1 API`, { userId })
const result = await performFullUndeploy({ workflowId: id, userId, requestId })
if (!result.success) {
@@ -172,7 +172,7 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ error: error.message }, { status: error.status })
}
const message = getErrorMessage(error, 'Unknown error')
- logger.error(`[${requestId}] Workflow undeploy error`, { error: message })
+ logger.error('Workflow undeploy error', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/v1/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/workflows/[id]/export/route.ts
index af48925e638..f68f5a2a4e3 100644
--- a/apps/sim/app/api/v1/workflows/[id]/export/route.ts
+++ b/apps/sim/app/api/v1/workflows/[id]/export/route.ts
@@ -2,7 +2,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { v1ExportWorkflowContract } from '@/lib/api/contracts/v1/workflows'
import { parseRequest } from '@/lib/api/server'
@@ -33,8 +32,6 @@ export const revalidate = 0
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateId().slice(0, 8)
-
try {
const rateLimit = await checkRateLimit(request, 'workflow-export')
if (!rateLimit.allowed) {
@@ -50,7 +47,7 @@ export const GET = withRouteHandler(
const { id } = parsed.data.params
- logger.info(`[${requestId}] Exporting workflow ${id}`, { userId })
+ logger.info(`Exporting workflow ${id}`, { userId })
const workflowData = await getActiveWorkflowRecord(id)
if (!workflowData?.workspaceId) {
@@ -95,7 +92,7 @@ export const GET = withRouteHandler(
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
- logger.error(`[${requestId}] Workflow export error`, { error: message })
+ logger.error('Workflow export error', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts
index e1a2317dafe..030af8c879d 100644
--- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts
+++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts
@@ -75,10 +75,7 @@ export const POST = withRouteHandler(
targetVersion = previous.version
}
- logger.info(
- `[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v1 API`,
- { userId }
- )
+ logger.info(`Rolling back workflow ${id} to version ${targetVersion} via v1 API`, { userId })
const result = await performActivateVersion({
workflowId: id,
@@ -117,7 +114,7 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: error.message }, { status: error.status })
}
const message = getErrorMessage(error, 'Unknown error')
- logger.error(`[${requestId}] Workflow rollback error`, { error: message })
+ logger.error('Workflow rollback error', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/v1/workflows/[id]/route.ts b/apps/sim/app/api/v1/workflows/[id]/route.ts
index 1e01b403a3b..1bf194a5f58 100644
--- a/apps/sim/app/api/v1/workflows/[id]/route.ts
+++ b/apps/sim/app/api/v1/workflows/[id]/route.ts
@@ -3,7 +3,6 @@ import { workflowBlocks } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v1GetWorkflowContract } from '@/lib/api/contracts/v1/workflows'
@@ -23,8 +22,6 @@ export const revalidate = 0
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateId().slice(0, 8)
-
try {
const rateLimit = await checkRateLimit(request, 'workflow-detail')
if (!rateLimit.allowed) {
@@ -40,7 +37,7 @@ export const GET = withRouteHandler(
const { id } = parsed.data.params
- logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId })
+ logger.info(`Fetching workflow details for ${id}`, { userId })
const workflowData = await getActiveWorkflowRecord(id)
if (!workflowData) {
@@ -94,7 +91,7 @@ export const GET = withRouteHandler(
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
- logger.error(`[${requestId}] Workflow details fetch error`, { error: message })
+ logger.error('Workflow details fetch error', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts
index 4401acc57bc..dcfbbace6f7 100644
--- a/apps/sim/app/api/v1/workflows/import/route.ts
+++ b/apps/sim/app/api/v1/workflows/import/route.ts
@@ -1,12 +1,12 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import {
type V1ImportWorkflowData,
v1ImportWorkflowContract,
} from '@/lib/api/contracts/v1/workflows'
import { parseRequest } from '@/lib/api/server'
+import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify'
import {
@@ -36,7 +36,7 @@ export const revalidate = 0
* route authenticates and renders the v1 envelope.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateId().slice(0, 8)
+ const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'workflow-import')
@@ -59,7 +59,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const { workspaceId, folderId, name, description } = parsed.data.body
- logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, {
+ logger.info(`Importing workflow into workspace ${workspaceId}`, {
userId,
folderId,
})
@@ -109,7 +109,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(apiResponse.body, { status: 201, headers: apiResponse.headers })
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
- logger.error(`[${requestId}] Workflow import error`, { error: message })
+ logger.error('Workflow import error', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/v1/workflows/route.ts b/apps/sim/app/api/v1/workflows/route.ts
index 7814a85ba8a..bc6a0cded4d 100644
--- a/apps/sim/app/api/v1/workflows/route.ts
+++ b/apps/sim/app/api/v1/workflows/route.ts
@@ -2,7 +2,6 @@ import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
import { and, asc, eq, gt, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v1ListWorkflowsContract } from '@/lib/api/contracts/v1/workflows'
@@ -40,8 +39,6 @@ function decodeCursor(cursor: string): CursorData | null {
}
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateId().slice(0, 8)
-
try {
const rateLimit = await checkRateLimit(request, 'workflows')
if (!rateLimit.allowed) {
@@ -61,7 +58,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const params = parsed.data.query
- logger.info(`[${requestId}] Fetching workflows for workspace ${params.workspaceId}`, {
+ logger.info(`Fetching workflows for workspace ${params.workspaceId}`, {
userId,
filters: {
folderId: params.folderId,
@@ -166,7 +163,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
- logger.error(`[${requestId}] Workflows fetch error`, { error: message })
+ logger.error('Workflows fetch error', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts
index c34498d5636..5c950b4a2fa 100644
--- a/apps/sim/app/api/v2/chat/route.ts
+++ b/apps/sim/app/api/v2/chat/route.ts
@@ -200,7 +200,7 @@ export const POST = withRouteHandler(
const messageId = generateId()
const requestId = generateId()
- let reqLogger = logger.withMetadata({ messageId, requestId })
+ let reqLogger = logger.withMetadata({ messageId, simRequestId: requestId })
try {
const workspaceAccess = await assertActiveWorkspaceAccess(workspaceId, userId)
@@ -284,7 +284,7 @@ export const POST = withRouteHandler(
return v2Error('INTERNAL_ERROR', 'Internal server error')
}
const chatId = resolvedChat.chatId
- reqLogger = logger.withMetadata({ chatId, messageId, requestId })
+ reqLogger = logger.withMetadata({ chatId, messageId, simRequestId: requestId })
/**
* Write this turn's display copy to the conversation the route just
diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts
index 3b8a7d30dbe..24fbbcd3417 100644
--- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts
+++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts
@@ -265,7 +265,8 @@ function streamPendingRun(
send({ type: 'final', data: presentRun(result) })
})
.catch((error) => {
- logger.error(`[${requestId}] v2 execute result stream failed`, {
+ logger.error('v2 execute result stream failed', {
+ requestId,
error: getErrorMessage(error, 'Unknown error'),
})
send({ type: 'error', error: 'Internal server error', status: 500 })
@@ -354,7 +355,7 @@ export const POST = withRouteHandler(
const incomingCallChain = parseCallChain(req.headers.get(SIM_VIA_HEADER))
const callChainError = validateCallChain(incomingCallChain)
if (callChainError) {
- logger.warn(`[${requestId}] Call chain rejected`, { workflowId, error: callChainError })
+ logger.warn('Call chain rejected', { workflowId, error: callChainError })
return v2Error('CONFLICT', callChainError, { details: { code: 'CALL_CHAIN_DEPTH_EXCEEDED' } })
}
const callChain = buildNextCallChain(incomingCallChain, workflowId)
@@ -654,7 +655,7 @@ export const POST = withRouteHandler(
} catch (error) {
const classified = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error)
if (classified) return classified
- logger.error(`[${requestId}] v2 execute failed`, {
+ logger.error('v2 execute failed', {
workflowId,
error: getErrorMessage(error, 'Unknown error'),
})
diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts
index 6d68c132c1b..fbe1e60d148 100644
--- a/apps/sim/app/api/wand/route.ts
+++ b/apps/sim/app/api/wand/route.ts
@@ -146,17 +146,17 @@ async function updateUserStatsForWand(
await checkAndBillPayerOverageThreshold(billingAttribution.billingEntity)
} catch (error) {
- logger.error(`[${requestId}] Failed to update user stats for wand usage`, error)
+ logger.error('Failed to update user stats for wand usage', error, { requestId })
}
}
export const POST = withRouteHandler(async (req: NextRequest) => {
const requestId = generateRequestId()
- logger.info(`[${requestId}] Received wand generation request`)
+ logger.info('Received wand generation request')
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized wand generation attempt`)
+ logger.warn('Unauthorized wand generation attempt')
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
@@ -177,7 +177,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
} = body
if (!prompt) {
- logger.warn(`[${requestId}] Invalid request: Missing prompt.`)
+ logger.warn('Invalid request: Missing prompt.')
return NextResponse.json(
{ success: false, error: 'Missing required field: prompt.' },
{ status: 400 }
@@ -193,7 +193,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
.limit(1)
if (!workflowRecord) {
- logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
+ logger.warn(`Workflow not found: ${workflowId}`)
return NextResponse.json({ success: false, error: 'Workflow not found' }, { status: 404 })
}
@@ -206,14 +206,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
if (!permission || (permission !== 'admin' && permission !== 'write')) {
logger.warn(
- `[${requestId}] User ${session.user.id} does not have write access to workspace for workflow ${workflowId}`
+ `User ${session.user.id} does not have write access to workspace for workflow ${workflowId}`
)
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 403 })
}
} else {
- logger.warn(
- `[${requestId}] Workflow ${workflowId} has no workspaceId; wand request blocked`
- )
+ logger.warn(`Workflow ${workflowId} has no workspaceId; wand request blocked`)
return NextResponse.json(
{
success: false,
@@ -258,12 +256,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
if (byokResult) {
isBYOK = true
activeOpenAIKey = byokResult.apiKey
- logger.info(`[${requestId}] Using BYOK OpenAI key for wand generation`)
+ logger.info('Using BYOK OpenAI key for wand generation')
}
}
if (!useWandAzure && !activeOpenAIKey) {
- logger.error(`[${requestId}] AI client not initialized. Missing API key.`)
+ logger.error('AI client not initialized. Missing API key.')
return NextResponse.json(
{ success: false, error: 'Wand generation service is not configured.' },
{ status: 503 }
@@ -346,9 +344,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
if (stream) {
try {
- logger.info(
- `[${requestId}] About to create stream with model: ${useWandAzure ? wandModelName : 'gpt-4o'}`
- )
+ logger.info(`About to create stream with model: ${useWandAzure ? wandModelName : 'gpt-4o'}`)
const apiUrl = useWandAzure
? `${azureEndpoint?.replace(/\/$/, '')}/openai/v1/responses?api-version=${azureApiVersion}`
@@ -379,7 +375,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
if (!response.ok) {
const errorText = await response.text()
- logger.error(`[${requestId}] API request failed`, {
+ logger.error('API request failed', {
status: response.status,
statusText: response.statusText,
error: errorText,
@@ -387,7 +383,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
- logger.info(`[${requestId}] Stream response received, starting processing`)
+ logger.info('Stream response received, starting processing')
const encoder = new TextEncoder()
const decoder = new TextDecoder()
@@ -421,7 +417,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const { done, value } = await reader.read()
if (done) {
- logger.info(`[${requestId}] Stream completed. Total chunks: ${chunkCount}`)
+ logger.info(`Stream completed. Total chunks: ${chunkCount}`)
await flushUsage()
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`))
controller.close()
@@ -450,7 +446,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const data = trimmed.slice(5).trim()
if (data === '[DONE]') {
- logger.info(`[${requestId}] Received [DONE] signal`)
+ logger.info('Received [DONE] signal')
await flushUsage()
@@ -498,7 +494,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
if (content) {
chunkCount++
if (chunkCount === 1) {
- logger.info(`[${requestId}] Received first content chunk`)
+ logger.info('Received first content chunk')
}
controller.enqueue(
@@ -515,15 +511,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
completion_tokens: usage.completionTokens,
total_tokens: usage.totalTokens,
}
- logger.info(
- `[${requestId}] Received usage data: ${JSON.stringify(finalUsage)}`
- )
+ logger.info(`Received usage data: ${JSON.stringify(finalUsage)}`)
}
}
}
}
} catch (streamError: any) {
- logger.error(`[${requestId}] Streaming error`, {
+ logger.error('Streaming error', {
name: streamError?.name,
message: streamError?.message || 'Unknown error',
stack: streamError?.stack,
@@ -532,7 +526,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
try {
await flushUsage()
} catch (usageError) {
- logger.warn(`[${requestId}] Failed to record usage after stream error`, usageError)
+ logger.warn('Failed to record usage after stream error', usageError)
}
const errorData = `data: ${JSON.stringify({ error: 'Streaming failed', done: true })}\n\n`
@@ -554,7 +548,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
},
})
} catch (error: any) {
- logger.error(`[${requestId}] Failed to create stream`, {
+ logger.error('Failed to create stream', {
name: error?.name,
message: error?.message || 'Unknown error',
code: error?.code,
@@ -612,16 +606,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const generatedContent = extractResponseText(completion.output)?.trim()
if (!generatedContent) {
- logger.error(
- `[${requestId}] ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} response was empty or invalid.`
- )
+ logger.error(`${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} response was empty or invalid.`)
return NextResponse.json(
{ success: false, error: 'Failed to generate content. AI response was empty.' },
{ status: 500 }
)
}
- logger.info(`[${requestId}] Wand generation successful`)
+ logger.info('Wand generation successful')
const usage = parseResponsesUsage(completion.usage)
if (usage) {
@@ -639,7 +631,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
return NextResponse.json({ success: true, content: generatedContent })
} catch (error: any) {
- logger.error(`[${requestId}] Wand generation failed`, {
+ logger.error('Wand generation failed', {
name: error?.name,
message: error?.message || 'Unknown error',
code: error?.code,
diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts
index 5b3b7571f06..41803bcc8f3 100644
--- a/apps/sim/app/api/webhooks/[id]/route.ts
+++ b/apps/sim/app/api/webhooks/[id]/route.ts
@@ -33,8 +33,6 @@ export const dynamic = 'force-dynamic'
// Get a specific webhook
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
-
try {
const parsed = await parseRequest(getWebhookContract, request, context)
if (!parsed.success) return parsed.response
@@ -42,7 +40,7 @@ export const GET = withRouteHandler(
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
- logger.warn(`[${requestId}] Unauthorized webhook access attempt`)
+ logger.warn('Unauthorized webhook access attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
@@ -63,7 +61,7 @@ export const GET = withRouteHandler(
.limit(1)
if (webhooks.length === 0) {
- logger.warn(`[${requestId}] Webhook not found: ${id}`)
+ logger.warn(`Webhook not found: ${id}`)
return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
}
@@ -77,14 +75,14 @@ export const GET = withRouteHandler(
const hasAccess = authorization.allowed
if (!hasAccess) {
- logger.warn(`[${requestId}] User ${userId} denied access to webhook: ${id}`)
+ logger.warn(`User ${userId} denied access to webhook: ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
- logger.info(`[${requestId}] Successfully retrieved webhook: ${id}`)
+ logger.info(`Successfully retrieved webhook: ${id}`)
return NextResponse.json({ webhook: webhooks[0] }, { status: 200 })
} catch (error) {
- logger.error(`[${requestId}] Error fetching webhook`, error)
+ logger.error('Error fetching webhook', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
@@ -92,8 +90,6 @@ export const GET = withRouteHandler(
export const PATCH = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
-
try {
const parsed = await parseRequest(updateWebhookContract, request, context)
if (!parsed.success) return parsed.response
@@ -101,7 +97,7 @@ export const PATCH = withRouteHandler(
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
- logger.warn(`[${requestId}] Unauthorized webhook update attempt`)
+ logger.warn('Unauthorized webhook update attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
@@ -123,7 +119,7 @@ export const PATCH = withRouteHandler(
.limit(1)
if (webhooks.length === 0) {
- logger.warn(`[${requestId}] Webhook not found: ${id}`)
+ logger.warn(`Webhook not found: ${id}`)
return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
}
@@ -136,7 +132,7 @@ export const PATCH = withRouteHandler(
const canModify = authorization.allowed
if (!canModify) {
- logger.warn(`[${requestId}] User ${userId} denied permission to modify webhook: ${id}`)
+ logger.warn(`User ${userId} denied permission to modify webhook: ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
await assertWorkflowMutable(webhookData.workflow.id)
@@ -176,7 +172,7 @@ export const PATCH = withRouteHandler(
}
if (Object.keys(setClause).length === 0) {
- logger.info(`[${requestId}] No-op webhook PATCH (no field changes): ${id}`)
+ logger.info(`No-op webhook PATCH (no field changes): ${id}`)
return NextResponse.json({ webhook: webhooks[0].webhook }, { status: 200 })
}
@@ -187,14 +183,14 @@ export const PATCH = withRouteHandler(
.where(eq(webhook.id, id))
.returning()
- logger.info(`[${requestId}] Successfully updated webhook: ${id}`)
+ logger.info(`Successfully updated webhook: ${id}`)
return NextResponse.json({ webhook: updatedWebhook[0] }, { status: 200 })
} catch (error) {
if (error instanceof WorkflowLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
- logger.error(`[${requestId}] Error updating webhook`, error)
+ logger.error('Error updating webhook', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
@@ -212,7 +208,7 @@ export const DELETE = withRouteHandler(
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
- logger.warn(`[${requestId}] Unauthorized webhook deletion attempt`)
+ logger.warn('Unauthorized webhook deletion attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
@@ -233,7 +229,7 @@ export const DELETE = withRouteHandler(
.limit(1)
if (webhooks.length === 0) {
- logger.warn(`[${requestId}] Webhook not found: ${id}`)
+ logger.warn(`Webhook not found: ${id}`)
return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
}
@@ -247,7 +243,7 @@ export const DELETE = withRouteHandler(
const canDelete = authorization.allowed
if (!canDelete) {
- logger.warn(`[${requestId}] User ${userId} denied permission to delete webhook: ${id}`)
+ logger.warn(`User ${userId} denied permission to delete webhook: ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
await assertWorkflowMutable(webhookData.workflow.id)
@@ -266,7 +262,7 @@ export const DELETE = withRouteHandler(
// Telemetry should not fail the operation
}
- logger.info(`[${requestId}] Successfully deleted webhook: ${id}`)
+ logger.info(`Successfully deleted webhook: ${id}`)
recordAudit({
workspaceId: webhookData.workflow.workspaceId || null,
@@ -306,7 +302,7 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ error: error.message }, { status: error.status })
}
- logger.error(`[${requestId}] Error deleting webhook`, {
+ logger.error('Error deleting webhook', {
error: error.message,
stack: error.stack,
})
diff --git a/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts b/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts
index ba715ab2ee8..5a7346c59c6 100644
--- a/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts
+++ b/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts
@@ -13,7 +13,7 @@ export const maxDuration = 300 // Allow up to 5 minutes for cleanup
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
- logger.info(`Idempotency cleanup triggered (${requestId})`)
+ logger.info('Idempotency cleanup triggered')
try {
const authError = verifyCronAuth(request, 'Idempotency key cleanup')
@@ -45,7 +45,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
},
})
} catch (error) {
- logger.error(`Error during idempotency cleanup (${requestId}):`, error)
+ logger.error('Error during idempotency cleanup:', error)
return NextResponse.json(
{
success: false,
diff --git a/apps/sim/app/api/webhooks/poll/[provider]/route.ts b/apps/sim/app/api/webhooks/poll/[provider]/route.ts
index 8c09260dbb9..874062e980d 100644
--- a/apps/sim/app/api/webhooks/poll/[provider]/route.ts
+++ b/apps/sim/app/api/webhooks/poll/[provider]/route.ts
@@ -1,12 +1,12 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { generateShortId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { webhookPollingContract } from '@/lib/api/contracts/webhooks'
import { parseRequest } from '@/lib/api/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { runDetached } from '@/lib/core/utils/background'
+import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { pollProvider, VALID_POLLING_PROVIDERS } from '@/lib/webhooks/polling'
@@ -20,7 +20,7 @@ export const maxDuration = 180
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ provider: string }> }) => {
- const requestId = generateShortId()
+ const requestId = generateRequestId()
let provider: string | undefined
try {
@@ -75,7 +75,7 @@ export const GET = withRouteHandler(
)
} catch (error) {
const providerLabel = provider ?? 'webhook'
- logger.error(`Error during ${providerLabel} polling (${requestId}):`, error)
+ logger.error(`Error during ${providerLabel} polling:`, error)
return NextResponse.json(
{
success: false,
diff --git a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts
index 518bde8b878..636173abf51 100644
--- a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts
+++ b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts
@@ -83,7 +83,7 @@ export const POST = withRouteHandler(
json.length === 0 ||
json.length > QUICKBOOKS_WEBHOOK_MAX_EVENTS
) {
- logger.warn(`[${requestId}] Invalid QuickBooks webhook envelope`)
+ logger.warn('Invalid QuickBooks webhook envelope')
return NextResponse.json({ error: 'Invalid webhook envelope' }, { status: 400 })
}
@@ -95,7 +95,7 @@ export const POST = withRouteHandler(
else droppedCount += 1
}
if (droppedCount > 0) {
- logger.warn(`[${requestId}] Dropped unmodelled QuickBooks webhook events`, {
+ logger.warn('Dropped unmodelled QuickBooks webhook events', {
droppedCount,
eventCount: json.length,
})
@@ -112,13 +112,13 @@ export const POST = withRouteHandler(
receivedAt,
}
const jobId = await enqueueQuickBooksWebhookIngress(payload)
- logger.info(`[${requestId}] Accepted QuickBooks webhook delivery`, {
+ logger.info('Accepted QuickBooks webhook delivery', {
eventCount: events.length,
jobId,
})
return NextResponse.json({ ok: true })
} catch (error) {
- logger.error(`[${requestId}] QuickBooks webhook ingress error`, {
+ logger.error('QuickBooks webhook ingress error', {
error: getErrorMessage(error, 'Unknown error'),
})
return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 })
diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts
index 22468160cb5..dc93062b21f 100644
--- a/apps/sim/app/api/webhooks/route.ts
+++ b/apps/sim/app/api/webhooks/route.ts
@@ -62,7 +62,8 @@ async function revertSavedWebhook(
updatedAt: existingWebhook.updatedAt,
})
.where(eq(webhook.id, savedWebhook.id))
- logger.info(`[${requestId}] Restored previous webhook configuration after failed re-save`, {
+ logger.info('Restored previous webhook configuration after failed re-save', {
+ requestId,
webhookId: savedWebhook.id,
})
return
@@ -73,12 +74,10 @@ async function revertSavedWebhook(
// Get all webhooks for the current user
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
try {
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized webhooks access attempt`)
+ logger.warn('Unauthorized webhooks access attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -96,7 +95,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
.limit(1)
if (!wf.length) {
- logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
+ logger.warn(`Workflow not found: ${workflowId}`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
@@ -110,7 +109,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!canRead) {
logger.warn(
- `[${requestId}] User ${session.user.id} denied permission to read webhooks for workflow ${workflowId}`
+ `User ${session.user.id} denied permission to read webhooks for workflow ${workflowId}`
)
return NextResponse.json({ webhooks: [] }, { status: 200 })
}
@@ -146,7 +145,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
.orderBy(desc(webhook.updatedAt))
logger.info(
- `[${requestId}] Retrieved ${webhooks.length} webhooks for workflow ${workflowId} block ${blockId}`
+ `Retrieved ${webhooks.length} webhooks for workflow ${workflowId} block ${blockId}`
)
return NextResponse.json({ webhooks }, { status: 200 })
}
@@ -174,10 +173,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(and(inArray(workflow.workspaceId, workspaceIds), isNull(webhook.archivedAt)))
- logger.info(`[${requestId}] Retrieved ${webhooks.length} workspace-accessible webhooks`)
+ logger.info(`Retrieved ${webhooks.length} workspace-accessible webhooks`)
return NextResponse.json({ webhooks }, { status: 200 })
} catch (error) {
- logger.error(`[${requestId}] Error fetching webhooks`, error)
+ logger.error('Error fetching webhooks', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -189,7 +188,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const userId = session?.user?.id
if (!userId) {
- logger.warn(`[${requestId}] Unauthorized webhook creation attempt`)
+ logger.warn('Unauthorized webhook creation attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -203,7 +202,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
// Validate input
if (!workflowId) {
- logger.warn(`[${requestId}] Missing required fields for webhook creation`, {
+ logger.warn('Missing required fields for webhook creation', {
hasWorkflowId: !!workflowId,
hasPath: !!path,
})
@@ -251,19 +250,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (existingForBlock.length > 0) {
finalPath = existingForBlock[0].path ?? ''
- logger.info(
- `[${requestId}] Reusing existing generated path for ${provider} trigger: ${finalPath}`
- )
+ logger.info(`Reusing existing generated path for ${provider} trigger: ${finalPath}`)
}
}
// If still no path, generate a new dummy path (first-time save)
if (!finalPath || finalPath.trim() === '') {
finalPath = `${provider}-${generateId()}`
- logger.info(`[${requestId}] Generated webhook path for ${provider} trigger: ${finalPath}`)
+ logger.info(`Generated webhook path for ${provider} trigger: ${finalPath}`)
}
} else {
- logger.warn(`[${requestId}] Missing path for webhook creation`, {
+ logger.warn('Missing path for webhook creation', {
hasWorkflowId: !!workflowId,
hasPath: !!path,
})
@@ -291,7 +288,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
.limit(1)
if (workflowData.length === 0) {
- logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
+ logger.warn(`Workflow not found: ${workflowId}`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
@@ -305,9 +302,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const canModify = authorization.allowed
if (!canModify) {
- logger.warn(
- `[${requestId}] User ${userId} denied permission to modify webhook for workflow ${workflowId}`
- )
+ logger.warn(`User ${userId} denied permission to modify webhook for workflow ${workflowId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
await assertWorkflowMutable(workflowId)
@@ -347,7 +342,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
workflowId,
})
if (conflictingOwner) {
- logger.warn(`[${requestId}] Webhook path conflict: ${finalPath}`)
+ logger.warn(`Webhook path conflict: ${finalPath}`)
return NextResponse.json(
{ error: 'Webhook path already exists.', code: 'PATH_EXISTS' },
{ status: 409 }
@@ -445,7 +440,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
: false
if (withheld) {
logger.warn(
- `[${requestId}] Webhook ${existingWebhook ? 'reactivation' : 'creation'} blocked by permission group`,
+ `Webhook ${existingWebhook ? 'reactivation' : 'creation'} blocked by permission group`,
{
userId,
workflowId,
@@ -500,7 +495,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ credentialId, workflowId }
)
if (!credentialAccess.ok) {
- logger.warn(`[${requestId}] Webhook credential reference denied`, {
+ logger.warn('Webhook credential reference denied', {
userId,
workflowId,
credentialId,
@@ -576,11 +571,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
} catch (err) {
const isConfigurationError = err instanceof WebhookDeploymentConfigurationError
if (isConfigurationError) {
- logger.warn(`[${requestId}] Rejected webhook trigger configuration`, {
+ logger.warn('Rejected webhook trigger configuration', {
error: err.message,
})
} else {
- logger.error(`[${requestId}] Error creating external webhook subscription`, err)
+ logger.error('Error creating external webhook subscription', err)
}
return NextResponse.json(
{
@@ -597,7 +592,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
try {
if (targetWebhookId) {
- logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`, {
+ logger.info(`Updating existing webhook for path: ${finalPath}`, {
webhookId: targetWebhookId,
provider,
hasCredentialId: !!(configToSave as any)?.credentialId,
@@ -618,14 +613,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
.where(eq(webhook.id, targetWebhookId))
.returning()
savedWebhook = updatedResult[0]
- logger.info(`[${requestId}] Webhook updated successfully`, {
+ logger.info('Webhook updated successfully', {
webhookId: savedWebhook.id,
savedProviderConfig: savedWebhook.providerConfig,
})
} else {
// Create a new webhook
const webhookId = generateShortId()
- logger.info(`[${requestId}] Creating new webhook with ID: ${webhookId}`)
+ logger.info(`Creating new webhook with ID: ${webhookId}`)
const newResult = await db
.insert(webhook)
.values({
@@ -647,7 +642,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
} catch (dbError) {
if (externalSubscriptionCreated) {
- logger.error(`[${requestId}] DB save failed, cleaning up external subscription`, dbError)
+ logger.error('DB save failed, cleaning up external subscription', dbError)
try {
await cleanupExternalWebhook(
createTempWebhookData(omit(configToSave, ['previousSubscription'])),
@@ -656,7 +651,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
} catch (cleanupError) {
logger.error(
- `[${requestId}] Failed to cleanup external subscription after DB save failure`,
+ 'Failed to cleanup external subscription after DB save failure',
cleanupError
)
}
@@ -712,7 +707,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
await cleanupExternalWebhook(existingWebhook, workflowRecord, requestId)
} catch (cleanupError) {
logger.warn(
- `[${requestId}] Failed to cleanup previous external webhook subscription ${existingWebhook.id}`,
+ `Failed to cleanup previous external webhook subscription ${existingWebhook.id}`,
cleanupError
)
}
@@ -721,9 +716,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (savedWebhook) {
const pollingHandler = getProviderHandler(provider)
if (pollingHandler.configurePolling) {
- logger.info(
- `[${requestId}] ${provider} provider detected. Setting up polling configuration.`
- )
+ logger.info(`${provider} provider detected. Setting up polling configuration.`)
try {
const success = await pollingHandler.configurePolling({
webhook: savedWebhook,
@@ -735,9 +728,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
if (!success) {
- logger.error(
- `[${requestId}] Failed to configure ${provider} polling, rolling back webhook`
- )
+ logger.error(`Failed to configure ${provider} polling, rolling back webhook`)
await revertSavedWebhook(savedWebhook, existingWebhook, requestId)
return NextResponse.json(
{
@@ -748,10 +739,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
- logger.info(`[${requestId}] Successfully configured ${provider} polling`)
+ logger.info(`Successfully configured ${provider} polling`)
} catch (err) {
logger.error(
- `[${requestId}] Error setting up ${provider} webhook configuration, rolling back webhook`,
+ `Error setting up ${provider} webhook configuration, rolling back webhook`,
err
)
await revertSavedWebhook(savedWebhook, existingWebhook, requestId)
@@ -818,7 +809,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: error.message }, { status: error.status })
}
- logger.error(`[${requestId}] Error creating/updating webhook`, {
+ logger.error('Error creating/updating webhook', {
message: error.message,
stack: error.stack,
})
diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts
index 85b87c35b41..c967bb7a369 100644
--- a/apps/sim/app/api/webhooks/slack/route.ts
+++ b/apps/sim/app/api/webhooks/slack/route.ts
@@ -171,7 +171,7 @@ async function handleSlackAppWebhook(request: NextRequest): Promise {
rawBody = await readTikTokBody(request)
} catch (bodyError) {
if (isPayloadSizeLimitError(bodyError)) {
- logger.warn(`[${requestId}] Rejected oversized TikTok webhook body`, {
+ logger.warn('Rejected oversized TikTok webhook body', {
maxBytes: WEBHOOK_MAX_BODY_BYTES,
observedBytes: bodyError.observedBytes,
})
@@ -74,14 +74,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
try {
parsedJson = rawBody ? JSON.parse(rawBody) : {}
} catch {
- logger.warn(`[${requestId}] TikTok webhook body is not valid JSON`)
+ logger.warn('TikTok webhook body is not valid JSON')
// Ack to avoid retry storms on malformed payloads after a valid signature.
return NextResponse.json({ ok: true })
}
const envelopeResult = tiktokWebhookEnvelopeSchema.safeParse(parsedJson)
if (!envelopeResult.success) {
- logger.warn(`[${requestId}] Invalid TikTok webhook envelope`, {
+ logger.warn('Invalid TikTok webhook envelope', {
issues: envelopeResult.error.issues,
})
return NextResponse.json({ ok: true })
@@ -89,7 +89,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const envelope = envelopeResult.data
if (!env.TIKTOK_CLIENT_ID || envelope.client_key !== env.TIKTOK_CLIENT_ID) {
- logger.warn(`[${requestId}] TikTok webhook client_key does not match configured app`)
+ logger.warn('TikTok webhook client_key does not match configured app')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -107,7 +107,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (result.outcome === 'failed') failed += 1
}
- logger.info(`[${requestId}] Processed TikTok webhook delivery`, {
+ logger.info('Processed TikTok webhook delivery', {
dispatched,
failed,
event: envelope.event,
@@ -121,7 +121,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ ok: true })
} catch (error) {
- logger.error(`[${requestId}] TikTok webhook processing error`, {
+ logger.error('TikTok webhook processing error', {
error: getErrorMessage(error, 'Unknown error'),
})
return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 })
diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
index b201908cfc3..2e35e7f11ab 100644
--- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts
+++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
@@ -179,7 +179,7 @@ async function handleWebhookDelivery(
)
if (allWebhooksForPath.length > 0 && pathWebhooks.length === 0) {
- logger.warn(`[${requestId}] Rejected HTTP delivery to non-path trigger: ${path}`)
+ logger.warn(`Rejected HTTP delivery to non-path trigger: ${path}`, { requestId })
return notDeliverableResponse(request.method)
}
@@ -189,7 +189,8 @@ async function handleWebhookDelivery(
if (pathWebhooks.length > 0 && webhooksForPath.length === 0) {
logger.warn(
- `[${requestId}] Rejected ${request.method} delivery to path ${path}: no trigger on this path accepts that method`
+ `Rejected ${request.method} delivery to path ${path}: no trigger on this path accepts that method`,
+ { requestId }
)
return methodNotAllowedResponse()
}
@@ -205,7 +206,7 @@ async function handleWebhookDelivery(
return verificationResponse
}
- logger.debug(`[${requestId}] Webhook or workflow not found for path: ${path}`)
+ logger.debug(`Webhook or workflow not found for path: ${path}`, { requestId })
return notDeliverableResponse(request.method)
}
@@ -293,9 +294,9 @@ async function handleWebhookDelivery(
{ status: 500 }
)
if (dispatchTargetCount > 1) {
- logger.error(
- `[${requestId}] Webhook ${foundWebhook.id} has no provider, continuing to next`
- )
+ logger.error(`Webhook ${foundWebhook.id} has no provider, continuing to next`, {
+ requestId,
+ })
continue
}
return missingProviderResponse
@@ -310,7 +311,7 @@ async function handleWebhookDelivery(
)
if (authError) {
if (dispatchTargetCount > 1) {
- logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`)
+ logger.warn(`Auth failed for webhook ${foundWebhook.id}, continuing to next`, { requestId })
continue
}
return authError
@@ -346,10 +347,11 @@ async function handleWebhookDelivery(
if (dispatchResult.outcome === 'failed' || dispatchResult.reason === 'block-missing') {
if (dispatchTargetCount > 1) {
- logger.warn(
- `[${requestId}] Webhook dispatch failed for ${foundWebhook.id}, continuing to next`,
- { reason: dispatchResult.reason, status: dispatchResult.response.status }
- )
+ logger.warn(`Webhook dispatch failed for ${foundWebhook.id}, continuing to next`, {
+ requestId,
+ reason: dispatchResult.reason,
+ status: dispatchResult.response.status,
+ })
if (dispatchResult.outcome === 'failed') {
failures.push(dispatchResult.response)
} else {
@@ -381,7 +383,7 @@ async function handleWebhookDelivery(
}
// For multiple webhooks, return success if at least one succeeded
- logger.info(`[${requestId}] Processed ${responses.length} webhooks for path: ${path}`)
+ logger.info(`Processed ${responses.length} webhooks for path: ${path}`, { requestId })
return NextResponse.json({
success: true,
webhooksProcessed: responses.length,
diff --git a/apps/sim/app/api/workflows/[id]/autolayout/route.ts b/apps/sim/app/api/workflows/[id]/autolayout/route.ts
index 387047da880..94bc9fea409 100644
--- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts
+++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts
@@ -9,7 +9,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { workflowAutoLayoutContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { applyAutoLayout } from '@/lib/workflows/autolayout'
import {
@@ -32,14 +31,13 @@ const logger = createLogger('AutoLayoutAPI')
*/
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
const startTime = Date.now()
const { id: workflowId } = await context.params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
- logger.warn(`[${requestId}] Unauthorized autolayout attempt for workflow ${workflowId}`)
+ logger.warn(`Unauthorized autolayout attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -49,7 +47,7 @@ export const POST = withRouteHandler(
if (!parsed.success) return parsed.response
const layoutOptions = parsed.data.body
- logger.info(`[${requestId}] Processing autolayout request for workflow ${workflowId}`, {
+ logger.info(`Processing autolayout request for workflow ${workflowId}`, {
userId,
})
@@ -61,16 +59,14 @@ export const POST = withRouteHandler(
const workflowData = authorization.workflow
if (!workflowData) {
- logger.warn(`[${requestId}] Workflow ${workflowId} not found for autolayout`)
+ logger.warn(`Workflow ${workflowId} not found for autolayout`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const canUpdate = authorization.allowed
if (!canUpdate) {
- logger.warn(
- `[${requestId}] User ${userId} denied permission to autolayout workflow ${workflowId}`
- )
+ logger.warn(`User ${userId} denied permission to autolayout workflow ${workflowId}`)
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
{ status: authorization.status || 403 }
@@ -82,7 +78,7 @@ export const POST = withRouteHandler(
let currentWorkflowData: NormalizedWorkflowData | null
if (layoutOptions.blocks && layoutOptions.edges) {
- logger.info(`[${requestId}] Using provided blocks with live measurements`)
+ logger.info('Using provided blocks with live measurements')
currentWorkflowData = {
blocks: layoutOptions.blocks,
edges: layoutOptions.edges,
@@ -91,12 +87,12 @@ export const POST = withRouteHandler(
isFromNormalizedTables: false,
}
} else {
- logger.info(`[${requestId}] Loading blocks from database`)
+ logger.info('Loading blocks from database')
currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId)
}
if (!currentWorkflowData) {
- logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`)
+ logger.error(`Could not load workflow ${workflowId} for autolayout`)
return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 })
}
@@ -118,7 +114,7 @@ export const POST = withRouteHandler(
)
if (!layoutResult.success || !layoutResult.blocks) {
- logger.error(`[${requestId}] Auto layout failed:`, {
+ logger.error('Auto layout failed:', {
error: layoutResult.error,
})
return NextResponse.json(
@@ -133,7 +129,7 @@ export const POST = withRouteHandler(
const elapsed = Date.now() - startTime
const blockCount = Object.keys(layoutResult.blocks).length
- logger.info(`[${requestId}] Autolayout completed successfully in ${elapsed}ms`, {
+ logger.info(`Autolayout completed successfully in ${elapsed}ms`, {
blockCount,
workflowId,
})
@@ -154,7 +150,7 @@ export const POST = withRouteHandler(
const elapsed = Date.now() - startTime
- logger.error(`[${requestId}] Autolayout failed after ${elapsed}ms:`, error)
+ logger.error(`Autolayout failed after ${elapsed}ms:`, error)
return NextResponse.json(
{
error: 'Autolayout failed',
diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts
index 20bf893ee71..d95612287c8 100644
--- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts
+++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts
@@ -88,10 +88,10 @@ export const PATCH = withRouteHandler(
})
if (name !== undefined || description !== undefined) {
- logger.info(
- `[${requestId}] Updated deployment version ${version} metadata during activation`,
- { name, description }
- )
+ logger.info(`Updated deployment version ${version} metadata during activation`, {
+ name,
+ description,
+ })
}
return createSuccessResponse({
@@ -111,7 +111,7 @@ export const PATCH = withRouteHandler(
request,
})
- logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, {
+ logger.info(`Updated deployment version ${version} for workflow ${id}`, {
name,
description,
})
@@ -130,7 +130,7 @@ export const PATCH = withRouteHandler(
statusForOrchestrationError(orchestrationError.code)
)
}
- logger.error(`[${requestId}] Error updating deployment version`, { error })
+ logger.error('Error updating deployment version', { error })
return createCodedErrorResponse('Failed to update deployment version', 500)
}
}
diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts
index beba4a3f3ab..bde8f9900bd 100644
--- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts
+++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts
@@ -22,9 +22,7 @@ export const POST = withRouteHandler(
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
- logger.warn(
- `[${requestId}] Unauthorized workflow duplication attempt for ${sourceWorkflowId}`
- )
+ logger.warn(`Unauthorized workflow duplication attempt for ${sourceWorkflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
@@ -34,7 +32,7 @@ export const POST = withRouteHandler(
if (!parsed.success) return parsed.response
const { name, description, workspaceId, folderId, newId } = parsed.data.body
- logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`)
+ logger.info(`Duplicating workflow ${sourceWorkflowId} for user ${userId}`)
const result = await duplicateWorkflow({
sourceWorkflowId,
@@ -70,7 +68,7 @@ export const POST = withRouteHandler(
const elapsed = Date.now() - startTime
logger.info(
- `[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms`
+ `Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms`
)
recordAudit({
@@ -99,20 +97,18 @@ export const POST = withRouteHandler(
}
if (error.message === 'Source workflow not found') {
- logger.warn(`[${requestId}] Source workflow ${sourceWorkflowId} not found`)
+ logger.warn(`Source workflow ${sourceWorkflowId} not found`)
return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 })
}
if (error.message === 'Source workflow not found or access denied') {
- logger.warn(
- `[${requestId}] User ${userId} denied access to source workflow ${sourceWorkflowId}`
- )
+ logger.warn(`User ${userId} denied access to source workflow ${sourceWorkflowId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
if (error.message === 'Cross-workspace workflow duplication is not supported') {
logger.warn(
- `[${requestId}] User ${userId} attempted cross-workspace workflow duplication for ${sourceWorkflowId}`
+ `User ${userId} attempted cross-workspace workflow duplication for ${sourceWorkflowId}`
)
return NextResponse.json({ error: error.message }, { status: 400 })
}
@@ -127,10 +123,7 @@ export const POST = withRouteHandler(
}
const elapsed = Date.now() - startTime
- logger.error(
- `[${requestId}] Error duplicating workflow ${sourceWorkflowId} after ${elapsed}ms:`,
- error
- )
+ logger.error(`Error duplicating workflow ${sourceWorkflowId} after ${elapsed}ms:`, error)
return NextResponse.json({ error: 'Failed to duplicate workflow' }, { status: 500 })
}
}
diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts
index b42dcdb9ce4..d95dee77ce0 100644
--- a/apps/sim/app/api/workflows/[id]/restore/route.ts
+++ b/apps/sim/app/api/workflows/[id]/restore/route.ts
@@ -66,7 +66,7 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: result.error }, { status })
}
- logger.info(`[${requestId}] Restored workflow ${workflowId}`)
+ logger.info(`Restored workflow ${workflowId}`)
captureServerEvent(
auth.userId,
@@ -81,7 +81,7 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: error.message }, { status: error.status })
}
- logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error)
+ logger.error(`Error restoring workflow ${workflowId}`, error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Internal server error') },
{ status: 500 }
diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts
index 27ef1e72c2d..d5b5931b043 100644
--- a/apps/sim/app/api/workflows/[id]/state/route.ts
+++ b/apps/sim/app/api/workflows/[id]/state/route.ts
@@ -99,7 +99,7 @@ export const PUT = withRouteHandler(
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
- logger.warn(`[${requestId}] Unauthorized state update attempt for workflow ${workflowId}`)
+ logger.warn(`Unauthorized state update attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
@@ -125,15 +125,12 @@ export const PUT = withRouteHandler(
}
const elapsed = Date.now() - startTime
- logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`)
+ logger.info(`Successfully saved workflow ${workflowId} state in ${elapsed}ms`)
return NextResponse.json({ success: true, warnings: result.warnings }, { status: 200 })
} catch (error: any) {
const elapsed = Date.now() - startTime
- logger.error(
- `[${requestId}] Error saving workflow ${workflowId} state after ${elapsed}ms`,
- error
- )
+ logger.error(`Error saving workflow ${workflowId} state after ${elapsed}ms`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts
index 09f6e6fb694..51171b6c56b 100644
--- a/apps/sim/app/api/workflows/[id]/variables/route.ts
+++ b/apps/sim/app/api/workflows/[id]/variables/route.ts
@@ -13,7 +13,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { workflowVariablesContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { Variable } from '@/stores/variables/types'
@@ -21,13 +20,12 @@ const logger = createLogger('WorkflowVariablesAPI')
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
const workflowId = (await context.params).id
try {
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
- logger.warn(`[${requestId}] Unauthorized workflow variables update attempt`)
+ logger.warn('Unauthorized workflow variables update attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
@@ -40,14 +38,14 @@ export const POST = withRouteHandler(
const workflowData = authorization.workflow
if (!workflowData) {
- logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
+ logger.warn(`Workflow not found: ${workflowId}`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const isAuthorized = authorization.allowed
if (!isAuthorized) {
logger.warn(
- `[${requestId}] User ${userId} attempted to update variables for workflow ${workflowId} without permission`
+ `User ${userId} attempted to update variables for workflow ${workflowId} without permission`
)
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
@@ -99,7 +97,7 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: error.message }, { status: error.status })
}
- logger.error(`[${requestId}] Error updating workflow variables`, error)
+ logger.error('Error updating workflow variables', error)
return NextResponse.json({ error: 'Failed to update workflow variables' }, { status: 500 })
}
}
@@ -107,13 +105,12 @@ export const POST = withRouteHandler(
export const GET = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
const workflowId = (await params).id
try {
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
- logger.warn(`[${requestId}] Unauthorized workflow variables access attempt`)
+ logger.warn('Unauthorized workflow variables access attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
@@ -126,14 +123,14 @@ export const GET = withRouteHandler(
const workflowData = authorization.workflow
if (!workflowData) {
- logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
+ logger.warn(`Workflow not found: ${workflowId}`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const isAuthorized = authorization.allowed
if (!isAuthorized) {
logger.warn(
- `[${requestId}] User ${userId} attempted to access variables for workflow ${workflowId} without permission`
+ `User ${userId} attempted to access variables for workflow ${workflowId} without permission`
)
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
@@ -162,7 +159,7 @@ export const GET = withRouteHandler(
}
)
} catch (error) {
- logger.error(`[${requestId}] Workflow variables fetch error`, error)
+ logger.error('Workflow variables fetch error', error)
const errorMessage = getErrorMessage(error, 'Unknown error')
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
diff --git a/apps/sim/app/api/workflows/reorder/route.ts b/apps/sim/app/api/workflows/reorder/route.ts
index 45ab9f9addd..9a8a28731a5 100644
--- a/apps/sim/app/api/workflows/reorder/route.ts
+++ b/apps/sim/app/api/workflows/reorder/route.ts
@@ -14,7 +14,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { reorderWorkflowsContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -22,10 +21,9 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkflowReorderAPI')
export const PUT = withRouteHandler(async (req: NextRequest) => {
- const requestId = generateRequestId()
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
- logger.warn(`[${requestId}] Unauthorized reorder attempt`)
+ logger.warn('Unauthorized reorder attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
@@ -37,9 +35,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission || permission === 'read') {
- logger.warn(
- `[${requestId}] User ${userId} lacks write permission for workspace ${workspaceId}`
- )
+ logger.warn(`User ${userId} lacks write permission for workspace ${workspaceId}`)
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
}
@@ -80,9 +76,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
}
})
- logger.info(
- `[${requestId}] Reordered ${validUpdates.length} workflows in workspace ${workspaceId}`
- )
+ logger.info(`Reordered ${validUpdates.length} workflows in workspace ${workspaceId}`)
await notifyWorkspaceWorkflowsChanged(workspaceId)
@@ -96,7 +90,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
return NextResponse.json({ error: error.message }, { status: error.status })
}
- logger.error(`[${requestId}] Error reordering workflows`, error)
+ logger.error('Error reordering workflows', error)
return NextResponse.json({ error: 'Failed to reorder workflows' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts
index 05d94a31b1d..6758f56c55d 100644
--- a/apps/sim/app/api/workflows/route.ts
+++ b/apps/sim/app/api/workflows/route.ts
@@ -15,7 +15,6 @@ import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowAPI')
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
const startTime = Date.now()
const url = new URL(request.url)
const query = workflowListQuerySchema.safeParse(Object.fromEntries(url.searchParams.entries()))
@@ -30,7 +29,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
- logger.warn(`[${requestId}] Unauthorized workflow access attempt`)
+ logger.warn('Unauthorized workflow access attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
@@ -39,9 +38,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const wsExists = await workspaceExists(workspaceId)
if (!wsExists) {
- logger.warn(
- `[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}`
- )
+ logger.warn(`Attempt to fetch workflows for non-existent workspace: ${workspaceId}`)
return NextResponse.json(
{ error: 'Workspace not found', code: 'WORKSPACE_NOT_FOUND' },
{ status: 404 }
@@ -52,7 +49,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!userRole) {
logger.warn(
- `[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership`
+ `User ${userId} attempted to access workspace ${workspaceId} without membership`
)
return NextResponse.json(
{ error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' },
@@ -66,7 +63,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ data: workflows }, { status: 200 })
} catch (error: any) {
const elapsed = Date.now() - startTime
- logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error)
+ logger.error(`Workflow fetch error after ${elapsed}ms`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -76,7 +73,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const requestId = generateRequestId()
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
- logger.warn(`[${requestId}] Unauthorized workflow creation attempt`)
+ logger.warn('Unauthorized workflow creation attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
@@ -95,7 +92,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
} = parsed.data.body
if (!workspaceId) {
- logger.warn(`[${requestId}] Workflow creation blocked: missing workspaceId`)
+ logger.warn('Workflow creation blocked: missing workspaceId')
return NextResponse.json(
{
error:
@@ -109,7 +106,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
if (!workspacePermission || workspacePermission === 'read') {
logger.warn(
- `[${requestId}] User ${userId} attempted to create workflow in workspace ${workspaceId} without write permissions`
+ `User ${userId} attempted to create workflow in workspace ${workspaceId} without write permissions`
)
return NextResponse.json(
{ error: 'Write or Admin access required to create workflows in this workspace' },
@@ -152,9 +149,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
// Silently fail
})
- logger.info(
- `[${requestId}] Successfully created workflow ${createdWorkflow.id} with default blocks`
- )
+ logger.info(`Successfully created workflow ${createdWorkflow.id} with default blocks`)
captureServerEvent(
userId,
@@ -186,7 +181,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
if (error instanceof FolderLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
- logger.error(`[${requestId}] Error creating workflow`, error)
+ logger.error('Error creating workflow', error)
return NextResponse.json({ error: 'Failed to create workflow' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/workspace-events/poll/route.ts b/apps/sim/app/api/workspace-events/poll/route.ts
index 7cde86e0d49..a3d63ccbf58 100644
--- a/apps/sim/app/api/workspace-events/poll/route.ts
+++ b/apps/sim/app/api/workspace-events/poll/route.ts
@@ -1,12 +1,12 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { generateShortId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { noInputSchema } from '@/lib/api/contracts/primitives'
import { validationErrorResponse } from '@/lib/api/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { runDetached } from '@/lib/core/utils/background'
+import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { pollNoActivityEvents } from '@/lib/workspace-events/no-activity'
@@ -18,8 +18,8 @@ const LOCK_KEY = 'workspace-events-no-activity-poll-lock'
const LOCK_TTL_SECONDS = 120
export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateShortId()
- logger.info(`Workspace events no-activity polling triggered (${requestId})`)
+ const requestId = generateRequestId()
+ logger.info('Workspace events no-activity polling triggered')
const queryValidation = noInputSchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries())
)
@@ -65,7 +65,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
{ status: 202 }
)
} catch (error) {
- logger.error(`Error during workspace events polling (${requestId}):`, error)
+ logger.error('Error during workspace events polling:', error)
return NextResponse.json(
{
success: false,
diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts
index 4ef2865e2f4..2dc2ea4ffdf 100644
--- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts
@@ -24,7 +24,6 @@ import { workspaceApiKeyErrorPolicy } from '@/lib/api-key/management-error-polic
import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
capabilityRefusal,
@@ -68,13 +67,12 @@ export const GET = defineInternalJsonRoute({
*/
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
const workspaceId = (await context.params).id
try {
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized workspace API key creation attempt`)
+ logger.warn('Unauthorized workspace API key creation attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -117,13 +115,13 @@ export const POST = withRouteHandler(
}
)
- logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
+ logger.info(`Created workspace API key: ${name} in workspace ${workspaceId}`)
return NextResponse.json({
key: result.key,
})
} catch (error: unknown) {
- logger.error(`[${requestId}] Workspace API key POST error`, error)
+ logger.error('Workspace API key POST error', error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to create workspace API key') },
{ status: 500 }
@@ -134,13 +132,12 @@ export const POST = withRouteHandler(
export const DELETE = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
const workspaceId = (await context.params).id
try {
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized workspace API key deletion attempt`)
+ logger.warn('Unauthorized workspace API key deletion attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -183,9 +180,7 @@ export const DELETE = withRouteHandler(
// Telemetry should not fail the operation
}
- logger.info(
- `[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}`
- )
+ logger.info(`Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}`)
recordAudit({
workspaceId,
@@ -201,7 +196,7 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ success: true, deletedCount })
} catch (error: unknown) {
- logger.error(`[${requestId}] Workspace API key DELETE error`, error)
+ logger.error('Workspace API key DELETE error', error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to delete workspace API keys') },
{ status: 500 }
diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts
index 96438089796..485c9f4a905 100644
--- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts
@@ -28,7 +28,6 @@ import {
import { workspaceByokErrorPolicy } from '@/lib/api-key/byok-error-policy'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
import { captureServerEvent } from '@/lib/posthog/server'
@@ -73,13 +72,12 @@ export const GET = defineInternalJsonRoute({
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
const workspaceId = (await context.params).id
try {
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized BYOK key creation attempt`)
+ logger.warn('Unauthorized BYOK key creation attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -127,7 +125,7 @@ export const POST = withRouteHandler(
})
.where(eq(workspaceBYOKKeys.id, existingKey.id))
- logger.info(`[${requestId}] Updated BYOK key for ${providerId} in workspace ${workspaceId}`)
+ logger.info(`Updated BYOK key for ${providerId} in workspace ${workspaceId}`)
recordAudit({
workspaceId,
@@ -208,7 +206,7 @@ export const POST = withRouteHandler(
)
}
- logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`)
+ logger.info(`Created BYOK key for ${providerId} in workspace ${workspaceId}`)
captureServerEvent(
userId,
@@ -242,7 +240,7 @@ export const POST = withRouteHandler(
},
})
} catch (error: unknown) {
- logger.error(`[${requestId}] BYOK key POST error`, error)
+ logger.error('BYOK key POST error', error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to save BYOK key') },
{ status: 500 }
diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts
index 1ed68d7adb2..8b8d51fad1d 100644
--- a/apps/sim/app/api/workspaces/[id]/environment/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts
@@ -13,7 +13,6 @@ import {
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
-import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { lockWorkspaceEnvMap } from '@/lib/credentials/env-locks'
import {
@@ -125,13 +124,12 @@ async function maskPersonalEnvForViewer({
export const GET = withRouteHandler(
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
const workspaceId = (await params).id
try {
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized workspace env access attempt`)
+ logger.warn('Unauthorized workspace env access attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -183,7 +181,7 @@ export const GET = withRouteHandler(
{ status: 200 }
)
} catch (error) {
- logger.error(`[${requestId}] Workspace env GET error`, error)
+ logger.error('Workspace env GET error', error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to load environment') },
{ status: 500 }
@@ -200,13 +198,12 @@ export const GET = withRouteHandler(
*/
export const PUT = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
const workspaceId = (await context.params).id
try {
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized workspace env update attempt`)
+ logger.warn('Unauthorized workspace env update attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -236,7 +233,7 @@ export const PUT = withRouteHandler(
const isKeyAdmin = (key: string) => permission === 'admin' || adminKeys.has(key)
const forbiddenExisting = incomingKeys.filter((k) => knownKeys.has(k) && !isKeyAdmin(k))
if (forbiddenExisting.length > 0) {
- logger.warn(`[${requestId}] Workspace env update denied`, {
+ logger.warn('Workspace env update denied', {
workspaceId,
userId,
reason: 'not-secret-admin',
@@ -252,7 +249,7 @@ export const PUT = withRouteHandler(
permission !== 'admin' &&
permission !== 'write'
) {
- logger.warn(`[${requestId}] Workspace env update denied`, {
+ logger.warn('Workspace env update denied', {
workspaceId,
userId,
reason: 'write-access-required',
@@ -343,7 +340,7 @@ export const PUT = withRouteHandler(
return NextResponse.json({ success: true })
} catch (error) {
- logger.error(`[${requestId}] Workspace env PUT error`, error)
+ logger.error('Workspace env PUT error', error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to update environment') },
{ status: 500 }
@@ -359,13 +356,12 @@ export const PUT = withRouteHandler(
*/
export const DELETE = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
const workspaceId = (await context.params).id
try {
const session = await getSession()
if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized workspace env delete attempt`)
+ logger.warn('Unauthorized workspace env delete attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -391,7 +387,7 @@ export const DELETE = withRouteHandler(
const isKeyAdmin = (key: string) => permission === 'admin' || adminKeys.has(key)
const forbiddenExisting = keys.filter((k) => knownKeys.has(k) && !isKeyAdmin(k))
if (forbiddenExisting.length > 0) {
- logger.warn(`[${requestId}] Workspace env delete denied`, {
+ logger.warn('Workspace env delete denied', {
workspaceId,
userId,
reason: 'not-secret-admin',
@@ -403,7 +399,7 @@ export const DELETE = withRouteHandler(
)
}
if (keys.some((k) => !knownKeys.has(k)) && permission !== 'admin' && permission !== 'write') {
- logger.warn(`[${requestId}] Workspace env delete denied`, {
+ logger.warn('Workspace env delete denied', {
workspaceId,
userId,
reason: 'write-access-required',
@@ -481,7 +477,7 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ success: true })
} catch (error) {
- logger.error(`[${requestId}] Workspace env DELETE error`, error)
+ logger.error('Workspace env DELETE error', error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to remove environment keys') },
{ status: 500 }
diff --git a/apps/sim/app/api/workspaces/route.test.ts b/apps/sim/app/api/workspaces/route.test.ts
index c275e42beb6..ce44469ac77 100644
--- a/apps/sim/app/api/workspaces/route.test.ts
+++ b/apps/sim/app/api/workspaces/route.test.ts
@@ -16,8 +16,9 @@ import {
} from '@sim/testing/mocks/workspaces-policy.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockCreateWorkspace } = vi.hoisted(() => ({
+const { mockCreateWorkspace, mockResolveRolledOutOrganizationId } = vi.hoisted(() => ({
mockCreateWorkspace: vi.fn(),
+ mockResolveRolledOutOrganizationId: vi.fn(),
}))
vi.mock('@/lib/auth/session-response', () => ({
@@ -32,6 +33,10 @@ vi.mock('@/lib/workspaces/list', () => ({
listWorkspacesForViewer: vi.fn(),
}))
+vi.mock('@/lib/navigation/resolve-app-entry', () => ({
+ resolveRolledOutOrganizationId: mockResolveRolledOutOrganizationId,
+}))
+
vi.mock('@/lib/posthog/server', () => posthogServerMock)
vi.mock('@sim/audit', () => auditMock)
@@ -99,3 +104,43 @@ describe('POST /api/workspaces capability refusal', () => {
await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' })
})
})
+
+describe('GET /api/workspaces blocked viewer forwarding', () => {
+ const blockedList = {
+ workspaces: [],
+ lastActiveWorkspaceId: null,
+ pinnedWorkspaceIds: [],
+ creationPolicy: { canCreate: false },
+ }
+ const routeContext = { params: Promise.resolve({}) }
+ const listRequest = () =>
+ createMockRequest('GET', undefined, {}, 'http://localhost/api/workspaces')
+
+ beforeEach(() => {
+ mockGetSession.mockResolvedValue({
+ user: { id: 'user-1', name: 'A', email: 'a@example.com' },
+ })
+ vi.mocked(listWorkspacesForViewer).mockResolvedValue(
+ blockedList as unknown as Awaited>
+ )
+ })
+
+ it('names the organization that can serve a viewer with nothing to pick', async () => {
+ mockResolveRolledOutOrganizationId.mockResolvedValue('org-1')
+ const response = await GET(listRequest(), routeContext)
+ expect((await response.json()).forwardOrganizationId).toBe('org-1')
+ })
+
+ it('leaves the blocked picker in place when no organization can serve them', async () => {
+ mockResolveRolledOutOrganizationId.mockResolvedValue(null)
+ const response = await GET(listRequest(), routeContext)
+ expect(await response.json()).not.toHaveProperty('forwardOrganizationId')
+ })
+
+ it('still returns the list when the organization lookup fails', async () => {
+ mockResolveRolledOutOrganizationId.mockRejectedValue(new Error('down'))
+ const response = await GET(listRequest(), routeContext)
+ expect(response.status).toBe(200)
+ expect(await response.json()).not.toHaveProperty('forwardOrganizationId')
+ })
+})
diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts
index f2eebe797de..28ec82d748c 100644
--- a/apps/sim/app/api/workspaces/route.ts
+++ b/apps/sim/app/api/workspaces/route.ts
@@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { type WorkspaceMode, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { getPostgresErrorCode } from '@sim/utils/errors'
+import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { listWorkspacesQuerySchema } from '@/lib/api/contracts'
@@ -11,6 +11,7 @@ import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { resolveRolledOutOrganizationId } from '@/lib/navigation/resolve-app-entry'
import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response'
import { captureServerEvent } from '@/lib/posthog/server'
import { createWorkspace } from '@/lib/workspaces/create'
@@ -24,6 +25,20 @@ import {
const logger = createLogger('Workspaces')
+/** A failed lookup leaves the viewer on the blocked picker rather than failing the list. */
+async function resolveForwardOrganizationId(session: {
+ user: { id: string }
+}): Promise {
+ try {
+ return (await resolveRolledOutOrganizationId(session)) ?? undefined
+ } catch (error) {
+ logger.warn('Could not resolve an organization to forward a blocked viewer to', {
+ error: getErrorMessage(error),
+ })
+ return undefined
+ }
+}
+
// Get all workspaces for the current user
export const GET = withRouteHandler(async (request: Request) => {
const session = await getSession()
@@ -58,6 +73,7 @@ export const GET = withRouteHandler(async (request: Request) => {
lastActiveWorkspaceId,
pinnedWorkspaceIds,
creationPolicy,
+ forwardOrganizationId: await resolveForwardOrganizationId(session),
})
}
diff --git a/apps/sim/app/benchmark/benchmark-loading.tsx b/apps/sim/app/benchmark/benchmark-loading.tsx
new file mode 100644
index 00000000000..7ab7cb85465
--- /dev/null
+++ b/apps/sim/app/benchmark/benchmark-loading.tsx
@@ -0,0 +1,17 @@
+import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
+
+export function BenchmarkConsoleLoading() {
+ return (
+
+
+
+
Benchmark
+
+ Loading benchmarks…
+
+
+
+ )
+}
diff --git a/apps/sim/app/benchmark/page.tsx b/apps/sim/app/benchmark/page.tsx
index e3f74ee8f3a..cf712726988 100644
--- a/apps/sim/app/benchmark/page.tsx
+++ b/apps/sim/app/benchmark/page.tsx
@@ -4,7 +4,7 @@ import { notFound } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { canUseBenchmarks } from '@/lib/benchmarks/config'
import { BenchmarkConsole } from '@/app/benchmark/benchmark-console'
-import BenchmarkLoading from '@/app/o/[organizationId]/benchmark/loading'
+import { BenchmarkConsoleLoading } from '@/app/benchmark/benchmark-loading'
export const metadata: Metadata = { title: 'Benchmarks' }
@@ -12,7 +12,7 @@ export default async function BenchmarkConsolePage() {
const session = await getSession()
if (!session?.user?.id || !(await canUseBenchmarks(session.user.id))) notFound()
return (
- }>
+ }>
)
diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx
index 9a207a92130..130cdbf3e74 100644
--- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx
+++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx
@@ -227,7 +227,7 @@ export default async function CredentialGroupEnrollmentPage({
)
const visibleOptions = focusedOption ? [focusedOption] : activeOptions
const focusedConnected =
- focusedOption?.connections[0]?.status === 'connected' &&
+ focusedOption?.connections.some((connection) => connection.status === 'connected') &&
(returnToSearch || connectedOptionId === focusedOption.id)
const connectedOption = connectedOptionId
? activeOptions.find((option) => option.id === connectedOptionId)
@@ -286,29 +286,34 @@ export default async function CredentialGroupEnrollmentPage({
{visibleOptions.map((option) => {
const ProviderIcon = getCredentialGroupProviderService(option.provider).icon
- const connection = option.connections[0]
+ const visibleConnections = option.connections.filter(
+ (connection) => connection.status !== 'revoked'
+ )
return (
}
title={option.label}
description={
- option.connections.length > 0
- ? option.connections.map((account) => (
-
+ visibleConnections.length > 0
+ ? visibleConnections.map((account) => (
+
{account.email}
{account.status === 'connected'
? ' — Connected'
- : ' — Reconnect required'}
+ : ' — Reconnect needed'}
))
: 'Not connected'
}
actions={
- focusedConnected ? undefined : (
+ focusedConnected &&
+ visibleConnections.every(
+ (account) => account.status === 'connected'
+ ) ? undefined : (
0}
variant={focused ? 'primary' : undefined}
/>
)
@@ -328,7 +333,7 @@ export default async function CredentialGroupEnrollmentPage({
server.connection?.status === 'connected'
? 'Connected'
: server.connection
- ? 'Reconnect required'
+ ? 'Reconnect needed'
: server.description || 'Not connected'
}
actions={
diff --git a/apps/sim/app/extension/o/[organizationId]/organization.tsx b/apps/sim/app/extension/o/[organizationId]/organization.tsx
index eaad05cbccc..3d102ff7a59 100644
--- a/apps/sim/app/extension/o/[organizationId]/organization.tsx
+++ b/apps/sim/app/extension/o/[organizationId]/organization.tsx
@@ -5,15 +5,15 @@ import { generateId } from '@sim/utils/id'
import { useQueryState } from 'nuqs'
import { isApiClientError } from '@/lib/api/client/errors'
import { organizationRoutes } from '@/lib/navigation/paths'
+import { OrganizationProvider } from '@/lib/organizations/provider'
import type { OrganizationSurfaceContext } from '@/lib/organizations/surface'
import { ExtensionHeader } from '@/app/extension/[workspaceId]/header'
import { extensionChatParam } from '@/app/extension/[workspaceId]/search-params'
import { useExtensionComposer } from '@/app/extension/hooks/use-composer'
import ExtensionLoading from '@/app/extension/loading'
+import { PanelConversation } from '@/app/extension/o/[organizationId]/panel-conversation'
import { ExtensionSessionExpired } from '@/app/extension/providers'
import { ExtensionUnavailable } from '@/app/extension/unavailable'
-import { OrganizationConversation } from '@/app/o/[organizationId]/components/organization-conversation'
-import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider'
import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -164,35 +164,37 @@ function ExtensionOrganizationConversation({
: routes.home
return (
- {
- onCommitMode()
- composer.onSubmitted()
- },
- draftNamespace: 'extension',
- onChatIdResolved,
- onNavigateToChat: onSelectChat,
- header: (
- <>
-
- {composer.captureError && (
-
- {composer.captureError}
-
- )}
- >
- ),
+ attachments={composer.attachments}
+ resourceActions={composer.resourceActions}
+ toolbarActions={composer.toolbarActions}
+ modelSelectorOptions={composer.modelSelectorOptions}
+ prepareSubmission={composer.prepareSubmission}
+ onSubmitted={() => {
+ onCommitMode()
+ composer.onSubmitted()
}}
+ draftNamespace='extension'
+ onChatIdResolved={onChatIdResolved}
+ onNavigateToChat={onSelectChat}
+ header={
+ <>
+
+ {composer.captureError && (
+
+ {composer.captureError}
+
+ )}
+ >
+ }
/>
)
diff --git a/apps/sim/app/extension/o/[organizationId]/panel-composer.tsx b/apps/sim/app/extension/o/[organizationId]/panel-composer.tsx
new file mode 100644
index 00000000000..2f47595908d
--- /dev/null
+++ b/apps/sim/app/extension/o/[organizationId]/panel-composer.tsx
@@ -0,0 +1,168 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import {
+ Chip,
+ ComposerActionButton,
+ cn,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+ Tooltip,
+} from '@sim/emcn'
+import { ArrowUp, Paperclip, Plus, Slash, StopFilled } from '@sim/emcn/icons'
+import { inter } from '@/app/_styles/fonts/inter/inter'
+import {
+ COMPOSER_FIELD_HEIGHT_CLASSES,
+ ComposerAttachments,
+ ComposerFileTargets,
+ type ConversationComposerProps,
+ useComposer,
+} from '@/app/o/[organizationId]/home/components/composer'
+import { InputToolbar } from '@/app/workspace/[workspaceId]/home/components/user-input/components/input-toolbar'
+import { MicButton } from '@/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button'
+import { MicrophonePermissionHelp } from '@/app/workspace/[workspaceId]/home/components/user-input/components/microphone-permission-help/microphone-permission-help'
+import type { ModelSelectorOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-selector'
+import { PromptEditor } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor'
+
+interface PanelComposerProps extends ConversationComposerProps {
+ attachments?: ReactNode
+ resourceActions?: ReactNode
+ toolbarActions?: ReactNode
+ modelSelectorOptions?: ModelSelectorOptions
+}
+
+/** The composer in the extension's side panel. */
+export function PanelComposer({
+ attachments,
+ resourceActions,
+ toolbarActions,
+ modelSelectorOptions,
+ ...props
+}: PanelComposerProps) {
+ const { files, requestMode, onPlanChange, isSending, onStop } = props
+ const composer = useComposer({
+ ...props,
+ waitsForFullLists: false,
+ hasAdditionalAttachments: Boolean(attachments),
+ })
+ const { editor, voice, imagesOnly } = composer
+ const attachLabel = imagesOnly ? 'Attach image' : 'Attach file'
+ return (
+
+
+
+
+ {imagesOnly && resourceActions && (
+
+
+
+
+ {resourceActions}
+
+ )}
+ {!imagesOnly && (
+
+ )}
+
+ {!imagesOnly && (
+ editor.insertSlashTrigger()}
+ />
+ )}
+ {toolbarActions}
+ {onPlanChange && (
+
+ Plan
+
+ )}
+ >
+ }
+ voiceControl={
+ voice.isSupported && (
+
+ )
+ }
+ submitControl={
+ isSending ? (
+
+
+
+ ) : (
+
+
+
+ )
+ }
+ />
+
+
+
+ )
+}
+
+interface ContextPickerProps {
+ icon: typeof Plus
+ label: string
+ onClick: (event: { currentTarget: Element }) => void
+}
+
+function ContextPicker({ icon, label, onClick }: ContextPickerProps) {
+ return (
+
+
+
+
+ {label}
+
+ )
+}
diff --git a/apps/sim/app/extension/o/[organizationId]/panel-conversation.tsx b/apps/sim/app/extension/o/[organizationId]/panel-conversation.tsx
new file mode 100644
index 00000000000..8bdd5d6e6b4
--- /dev/null
+++ b/apps/sim/app/extension/o/[organizationId]/panel-conversation.tsx
@@ -0,0 +1,128 @@
+'use client'
+
+import { type ReactNode, useRef } from 'react'
+import { ChipModal, ChipModalHeader } from '@sim/emcn'
+import { AssistantActionReviewProvider } from '@/components/assistant-actions'
+import { PanelComposer } from '@/app/extension/o/[organizationId]/panel-composer'
+import {
+ useConversationGate,
+ useOrganizationConversation,
+} from '@/app/o/[organizationId]/components/use-organization-conversation'
+import { ComposerScrollArea } from '@/app/workspace/[workspaceId]/home/components/composer-scroll-area'
+import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection'
+import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat'
+import { SourcesResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/sources-resource-content'
+import type { ModelSelectorOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-selector'
+import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
+import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types'
+import type { PrepareChatSubmission } from '@/hooks/use-composer-submission'
+
+interface PanelConversationProps {
+ chatId?: string
+ requestMode: ChatRequestMode
+ header: ReactNode
+ attachments?: ReactNode
+ resourceActions?: ReactNode
+ toolbarActions?: ReactNode
+ modelSelectorOptions?: ModelSelectorOptions
+ onChatIdResolved: (chatId: string) => void
+ onNavigateToChat: (chatId: string) => void
+ prepareSubmission: PrepareChatSubmission
+ onSubmitted: () => void
+ draftNamespace: string
+}
+
+/** The shared conversation in the extension's side panel. */
+export function PanelConversation(props: PanelConversationProps) {
+ const gate = useConversationGate(props)
+ if (gate.status === 'denied') return null
+ if (gate.status !== 'ready') return
+ return
+}
+
+function PanelConversationContent({
+ chatId,
+ requestMode,
+ header,
+ attachments,
+ resourceActions,
+ toolbarActions,
+ modelSelectorOptions,
+ onChatIdResolved,
+ onNavigateToChat,
+ prepareSubmission,
+ onSubmitted,
+ draftNamespace,
+}: PanelConversationProps) {
+ const fileDropTarget = useRef(null)
+ const { chat, controller, hasChat, composerProps, threadProps, reviewProps } =
+ useOrganizationConversation({
+ surface: {
+ kind: 'panel',
+ draftNamespace,
+ fileDropTarget,
+ onChatIdResolved,
+ prepareSubmission,
+ onSubmitted,
+ },
+ chatId,
+ requestMode,
+ })
+ const composer = (
+
+ )
+ const closeSources = () => controller.activeResourceState[1](null)
+ const sourceResource = chat.resources.find(
+ (resource) => resource.type === 'sources' && resource.id === controller.activeResourceParam
+ )
+ return (
+
+ {header}
+
+ {hasChat ? (
+
+
+
+ ) : (
+ <>
+
+
How can I help?
+
+
{composer}
+ >
+ )}
+
+ {
+ if (!open) closeSources()
+ }}
+ srTitle='Cited sources'
+ size='sm'
+ >
+ Cited sources
+ {sourceResource && (
+
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/sim/app/ingest/[[...path]]/route.test.ts b/apps/sim/app/ingest/[[...path]]/route.test.ts
index 252bcb47b72..767dabfcad4 100644
--- a/apps/sim/app/ingest/[[...path]]/route.test.ts
+++ b/apps/sim/app/ingest/[[...path]]/route.test.ts
@@ -31,7 +31,7 @@ describe('PostHog ingest proxy', () => {
const response = await POST(createIngestRequest(controller.signal))
expect(response.status).toBe(499)
- expect(logger.error).not.toHaveBeenCalled()
+ expect(logger.warn).not.toHaveBeenCalled()
})
it('reports an upstream failure while the client is still connected', async () => {
@@ -40,7 +40,7 @@ describe('PostHog ingest proxy', () => {
const response = await POST(createIngestRequest())
expect(response.status).toBe(502)
- expect(logger.error).toHaveBeenCalledWith(
+ expect(logger.warn).toHaveBeenCalledWith(
'PostHog proxy error',
expect.objectContaining({ error: 'The operation timed out.' })
)
diff --git a/apps/sim/app/ingest/[[...path]]/route.ts b/apps/sim/app/ingest/[[...path]]/route.ts
index 2117b1bf77f..c760613da35 100644
--- a/apps/sim/app/ingest/[[...path]]/route.ts
+++ b/apps/sim/app/ingest/[[...path]]/route.ts
@@ -61,7 +61,7 @@ async function handler(request: NextRequest) {
logger.info('Client closed PostHog proxy request', { method: request.method })
return new NextResponse(null, { status: 499 })
}
- logger.error('PostHog proxy error', {
+ logger.warn('PostHog proxy error', {
url,
method: request.method,
error: toError(error).message,
diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx
index 0a0269fe12e..000d206e3bf 100644
--- a/apps/sim/app/layout.tsx
+++ b/apps/sim/app/layout.tsx
@@ -115,8 +115,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
// The organization surface (/o/...) shares the workspace chrome and
// needs the same variables set before first paint.
+ var isOrganizationSurface = false;
try {
var path = window.location.pathname;
+ isOrganizationSurface = path.indexOf('/o/') === 0;
if (path.indexOf('/workspace/') === -1 && path.indexOf('/o/') !== 0 && path.indexOf('/account/settings') !== 0 && path.indexOf('/selfhost/settings') !== 0) {
return;
}
@@ -127,8 +129,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
// Sidebar width. Mirror getMaxSidebarWidth() in stores/sidebar/store.ts:
// 30% of the viewport capped at 400px, and never below the 224px
// minimum, so a narrow window yields a width >= MIN instead of a
- // sub-minimum sliver.
- var defaultSidebarWidth = 224;
+ // sub-minimum sliver. The organization rail opens at its own default.
+ var defaultSidebarWidth = isOrganizationSurface ? 280 : 224;
try {
// Collapse comes from the cookie (independent of localStorage
// parsing); the persisted width is read defensively below. Match the
@@ -154,6 +156,17 @@ export default function RootLayout({ children }: { children: React.ReactNode })
// collapsed, because the desktop hover-peek renders the sidebar at
// its restore width while --sidebar-width still reads collapsed.
var width = state && state.sidebarWidth;
+ // Mirror resolveExpandedWidth() in stores/sidebar/store.ts: the organization
+ // rail's own saved width, else a width dragged above the minimum elsewhere.
+ if (isOrganizationSurface) {
+ var organizationWidth = state && state.organizationSidebarWidth;
+ width =
+ typeof organizationWidth === 'number' && isFinite(organizationWidth)
+ ? organizationWidth
+ : width > 224
+ ? width
+ : null;
+ }
var maxSidebarWidth = Math.max(224, Math.min(400, window.innerWidth * 0.3));
var expandedWidth =
typeof width === 'number' && isFinite(width)
diff --git a/apps/sim/app/o/[organizationId]/benchmark/loading.tsx b/apps/sim/app/o/[organizationId]/benchmark/loading.tsx
index 3190860d8c8..64ccc79782b 100644
--- a/apps/sim/app/o/[organizationId]/benchmark/loading.tsx
+++ b/apps/sim/app/o/[organizationId]/benchmark/loading.tsx
@@ -1,17 +1,5 @@
-import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
+import { RouteStatus } from '@/platform/app/(shell)/_components/route-status/route-status'
export default function BenchmarkLoading() {
- return (
-
-
-
-
Benchmark
-
- Loading benchmarks…
-
-
-
- )
+ return
}
diff --git a/apps/sim/app/o/[organizationId]/chat/[chatId]/loading.tsx b/apps/sim/app/o/[organizationId]/chat/[chatId]/loading.tsx
index 4c707b1c49c..6983df2817b 100644
--- a/apps/sim/app/o/[organizationId]/chat/[chatId]/loading.tsx
+++ b/apps/sim/app/o/[organizationId]/chat/[chatId]/loading.tsx
@@ -1,12 +1,6 @@
-import { MothershipChatSkeleton } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/components/mothership-chat-skeleton'
+import { ConversationSkeleton } from '@/platform/app/(shell)/_components/conversation/skeletons'
/** Keep the transcript shape while the saved conversation is loading. */
export default function OrganizationChatLoading() {
- return (
-
- )
+ return
}
diff --git a/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx
index 39708fa67f7..cb2fc0d1a9e 100644
--- a/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx
+++ b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx
@@ -1,13 +1,16 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
-import { getSession } from '@/lib/auth'
import { getAccessibleCopilotChatAuth } from '@/lib/mothership/chat/lifecycle'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
-import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
+import {
+ canUseOrganizationChat,
+ getOrganizationBuildRedirect,
+} from '@/lib/organizations/home-redirect'
import OrganizationChatLoading from '@/app/o/[organizationId]/chat/[chatId]/loading'
-import { OrganizationConversation } from '@/app/o/[organizationId]/components/organization-conversation'
-import { getOrganizationHomeRedirect } from '@/app/o/[organizationId]/home/home-redirect'
+import { requireOrganizationViewer } from '@/app/o/[organizationId]/viewer'
+import { PageConversation } from '@/platform/app/(shell)/_components/conversation/page-conversation'
+import { ConversationSkeleton } from '@/platform/app/(shell)/_components/conversation/skeletons'
export const metadata: Metadata = { title: 'Chat' }
@@ -17,31 +20,33 @@ export default async function OrganizationChatPage({
params: Promise<{ organizationId: string; chatId: string }>
}) {
const { organizationId, chatId } = await params
- const session = await getSession()
- if (!session?.user?.id) notFound()
- const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
- if (!context) notFound()
- if (!context.mothershipAvailable || (!context.canBuild && !context.searchAccess.memberScoped)) {
- const unavailableRedirect = getOrganizationHomeRedirect(context, organizationId)
+ const {
+ userId,
+ principal,
+ surface: context,
+ } = await requireOrganizationViewer(
+ organizationId,
+ organizationRoutes(organizationId).chat(chatId)
+ )
+ if (!canUseOrganizationChat(context)) {
+ const unavailableRedirect = getOrganizationBuildRedirect(context, organizationId)
if (unavailableRedirect) redirect(unavailableRedirect)
}
- const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id, {
- principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id },
- })
+ const chat = await getAccessibleCopilotChatAuth(chatId, userId, { principal })
if (!chat || chat.type !== 'mothership' || chat.organizationId !== organizationId) notFound()
if (chat.mode === 'assistant') {
if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
redirect(organizationRoutes(organizationId).searchChat(chatId))
}
- const homeRedirect = getOrganizationHomeRedirect(context, organizationId)
- if (homeRedirect) redirect(homeRedirect)
+ const buildRedirect = getOrganizationBuildRedirect(context, organizationId)
+ if (buildRedirect) redirect(buildRedirect)
return (
}>
- }
/>
)
diff --git a/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx b/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx
index 6a4bb7647f1..966ba59d3ff 100644
--- a/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx
+++ b/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx
@@ -1,27 +1,30 @@
-import { ChipLink } from '@sim/emcn'
-import { CircleAlert } from '@sim/emcn/icons'
+import { cn } from '@sim/emcn'
+import Link from 'next/link'
import { WORKSPACES_PATH } from '@/lib/navigation/paths'
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
+import { RouteStatus } from '@/platform/app/(shell)/_components/route-status/route-status'
+import { Button } from '@/platform/design-system/components/button/button'
+import hostBridge from '@/platform/design-system/styles/host-bridge.module.css'
export function OrganizationAccessDenied() {
return (
-
+
-
-
-
-
-
-
Organization access denied
-
- You are not a member of this organization. Ask an organization admin to add you, or head
- back to your workspaces.
-
-
-
- View your workspaces
-
-
+ }>
+ View your workspaces
+
+ }
+ />
)
}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-conversation-landing.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-conversation-landing.test.tsx
new file mode 100644
index 00000000000..f2fefb65a4c
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-conversation-landing.test.tsx
@@ -0,0 +1,136 @@
+/**
+ * @vitest-environment jsdom
+ *
+ * Transient states a browser run cannot hold still. Failure modes: the landing rendering before the
+ * session arrives, and a restored chat entry flashing the dashboard while the router takes over.
+ */
+import { act, type ReactNode } from 'react'
+import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock'
+import { featureFlagsProviderMock } from '@sim/testing/mocks/feature-flags-provider.mock'
+import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
+import { kbConnectorsQueriesMock } from '@sim/testing/mocks/kb-connectors-queries.mock'
+import { nextNavigationMock, nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock'
+import {
+ chatResourcePanelMock,
+ mothershipChatMock,
+ mothershipChatsQueriesMock,
+ organizationConversationMockFns,
+ useChatMock,
+ useResourcePanelMock,
+} from '@sim/testing/mocks/organization-conversation.mock'
+import {
+ organizationProviderMock,
+ organizationProviderMockFns,
+} from '@sim/testing/mocks/organization-provider.mock'
+import {
+ createMutationResultMock,
+ createQueryResultMock,
+ reactQueryMock,
+ reactQueryMockFns,
+} from '@sim/testing/mocks/react-query.mock'
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ composer: vi.fn(),
+}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/providers/feature-flags-provider',
+ () => featureFlagsProviderMock
+)
+vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
+vi.mock('next/navigation', () => nextNavigationMock)
+vi.mock('@tanstack/react-query', () => reactQueryMock)
+vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-resource-panel', () => useResourcePanelMock)
+vi.mock(
+ '@/app/workspace/[workspaceId]/home/components/chat-resource-panel',
+ () => chatResourcePanelMock
+)
+vi.mock('@/lib/auth/client', () => authClientMock)
+vi.mock('@/lib/organizations/provider', () => organizationProviderMock)
+vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-chat', () => useChatMock)
+vi.mock('@/hooks/queries/mothership-chats', () => mothershipChatsQueriesMock)
+vi.mock('@/platform/app/(shell)/_components/conversation/page-composer', () => ({
+ PageComposer: mocks.composer,
+}))
+vi.mock('@/app/workspace/[workspaceId]/home/components/suggested-actions', () => ({
+ SuggestedActions: () => Suggested actions
,
+}))
+vi.mock('@/hooks/queries/kb/connectors', () => kbConnectorsQueriesMock)
+vi.mock('@/app/workspace/[workspaceId]/home/components/mothership-chat', () => mothershipChatMock)
+
+import {
+ type ConversationLandingSlot,
+ PageConversation,
+} from '@/platform/app/(shell)/_components/conversation/page-conversation'
+
+const { mockMothershipChat, mockUseChat } = organizationConversationMockFns
+
+const landing = ({ requestMode, composer }: ConversationLandingSlot) => (
+
+
Dashboard for {requestMode}
+ {composer}
+
+)
+
+let root: Root
+let container: HTMLDivElement
+
+async function render(element: ReactNode) {
+ await act(async () => {
+ root.render({element} )
+ })
+}
+
+beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ reactQueryMockFns.mockUseQuery.mockReturnValue(createQueryResultMock())
+ reactQueryMockFns.mockUseMutation.mockReturnValue(createMutationResultMock())
+ nextNavigationMockFns.mockUsePathname.mockReturnValue('/o/organization-a/home')
+ authClientMockFns.mockUseSession.mockReturnValue({ data: { user: { id: 'reader' } } })
+ organizationProviderMockFns.mockUseOrganization.mockReturnValue({
+ organization: { id: 'organization-a', name: 'Acme' },
+ searchAccess: { memberScoped: true },
+ mothershipAvailable: true,
+ canBuild: false,
+ viewer: { isAdmin: false, canUseSearchMcp: true },
+ })
+ mockUseChat.mockReturnValue({ messages: [], resources: [], sendMessage: vi.fn() })
+ mocks.composer.mockReturnValue(Question composer
)
+ mockMothershipChat.mockReturnValue(Conversation view
)
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+})
+
+describe('an Assistant conversation on a surface with its own landing', () => {
+ it('holds the surface’s fallback until the session arrives', async () => {
+ authClientMockFns.mockUseSession.mockReturnValue({ data: null })
+ await render(
+ Dashboard skeleton }
+ landing={landing}
+ />
+ )
+ expect(container.textContent).toBe('Dashboard skeleton')
+ })
+
+ it('shows no dashboard while a restored chat entry hands itself back to the router', async () => {
+ nextNavigationMockFns.mockUsePathname.mockReturnValue('/o/organization-a/search/chat/chat-1')
+ await render(
+ Dashboard skeleton}
+ landing={landing}
+ />
+ )
+ expect(container.textContent).toBe('')
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/components/organization-conversation-queued-edit.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-conversation-queued-edit.test.tsx
new file mode 100644
index 00000000000..00f3468731b
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-conversation-queued-edit.test.tsx
@@ -0,0 +1,244 @@
+/**
+ * @vitest-environment jsdom
+ *
+ * The real conversation hook and the real composer, with only the chat engine and the thread's layout
+ * stubbed. Failure modes: a keystroke re-rendering the thread, and a queued message coming back
+ * without its text, its chips, its attachments, or the viewer's focus once the conversation no longer
+ * holds the draft.
+ */
+
+import { act, type ReactNode } from 'react'
+import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock'
+import { stubResizeObserver } from '@sim/testing/mocks/browser-apis.mock'
+import {
+ createMockDeploymentShape,
+ deploymentShapeMock,
+ deploymentShapeMockFns,
+} from '@sim/testing/mocks/deployment-shape.mock'
+import { featureFlagsProviderMock } from '@sim/testing/mocks/feature-flags-provider.mock'
+import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
+import { nextNavigationMock, nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock'
+import {
+ chatResourcePanelMock,
+ mothershipChatMock,
+ mothershipChatsQueriesMock,
+ organizationConversationMockFns,
+ useChatMock,
+ useResourcePanelMock,
+} from '@sim/testing/mocks/organization-conversation.mock'
+import {
+ organizationProviderMock,
+ organizationProviderMockFns,
+} from '@sim/testing/mocks/organization-provider.mock'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
+
+const mocks = vi.hoisted(() => ({
+ send: vi.fn(),
+ editQueued: vi.fn(),
+ speech: vi.fn(),
+}))
+
+vi.mock('@/lib/auth/client', () => authClientMock)
+vi.mock('@/lib/core/config/deployment-shape', () => deploymentShapeMock)
+vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
+vi.mock('next/navigation', () => nextNavigationMock)
+vi.mock('@/lib/organizations/provider', () => organizationProviderMock)
+vi.mock(
+ '@/app/workspace/[workspaceId]/providers/feature-flags-provider',
+ () => featureFlagsProviderMock
+)
+vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-chat', () => useChatMock)
+vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-resource-panel', () => useResourcePanelMock)
+vi.mock(
+ '@/app/workspace/[workspaceId]/home/components/chat-resource-panel',
+ () => chatResourcePanelMock
+)
+vi.mock('@/app/workspace/[workspaceId]/home/components/mothership-chat', () => mothershipChatMock)
+vi.mock('@/components/assistant-actions', () => ({
+ AssistantActionReviewProvider: ({ children }: { children: ReactNode }) => children,
+}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown',
+ async () => {
+ const { forwardRef, useImperativeHandle } = await import('react')
+ return {
+ PlusMenuDropdown: forwardRef((_props, ref) => {
+ useImperativeHandle(ref, () => ({
+ open: vi.fn(),
+ close: vi.fn(),
+ moveActive: vi.fn(),
+ selectActive: () => 'empty',
+ }))
+ return null
+ }),
+ }
+ }
+)
+vi.mock('@/hooks/queries/mothership-chats', () => mothershipChatsQueriesMock)
+vi.mock('@/hooks/queries/workspace', () => ({
+ useWorkspacesQuery: () => ({ data: [] }),
+}))
+vi.mock('@/hooks/queries/mcp', () => ({ useMcpToolServers: () => ({ data: [] }) }))
+vi.mock('@/hooks/use-speech-to-text', () => ({ useSpeechToText: mocks.speech }))
+vi.mock('@/hooks/use-chat-input-focus', () => ({ useChatInputFocus: vi.fn() }))
+vi.mock('@/hooks/use-animated-placeholder', () => ({ useAnimatedPlaceholder: () => 'Ask Sim to' }))
+
+import { PageConversation } from '@/platform/app/(shell)/_components/conversation/page-conversation'
+
+const { mockMothershipChat, mockUseChat } = organizationConversationMockFns
+
+/** The editor swaps a chipped skill's `/` for a wide sentinel so its icon fits the overlay. */
+const QUEUED_AS_EDITED = 'Run \u2003review on this'
+const QUEUED = {
+ id: 'queued-1',
+ content: 'Run /review on this',
+ contexts: [{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' }],
+ fileAttachments: [
+ {
+ id: 'file-1',
+ key: 'uploads/file-1',
+ filename: 'notes.pdf',
+ media_type: 'application/pdf',
+ size: 12,
+ path: '/files/notes.pdf',
+ },
+ ],
+}
+
+let root: Root
+let container: HTMLDivElement
+let queryClient: QueryClient
+
+beforeEach(async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ stubResizeObserver()
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }))
+ )
+ HTMLElement.prototype.scrollIntoView = vi.fn()
+ useMothershipDraftsStore.setState({ drafts: {} })
+ const deploymentShape = () => createMockDeploymentShape()
+ deploymentShapeMockFns.mockUseDeploymentShape.mockImplementation(deploymentShape)
+ deploymentShapeMockFns.mockGetDeploymentShape.mockImplementation(deploymentShape)
+ nextNavigationMockFns.mockUsePathname.mockReturnValue('/o/organization-a/chat/chat-a')
+ authClientMockFns.mockUseSession.mockReturnValue({ data: { user: { id: 'reader' } } })
+ organizationProviderMockFns.mockUseOrganization.mockReturnValue({
+ organization: { id: 'organization-a', name: 'Acme' },
+ searchAccess: { memberScoped: true },
+ mothershipAvailable: true,
+ canBuild: true,
+ })
+ mocks.speech.mockReturnValue({
+ isSupported: false,
+ isListening: false,
+ audioLevels: new Float32Array(5),
+ toggleListening: vi.fn(),
+ resetTranscript: vi.fn(),
+ })
+ mocks.editQueued.mockReturnValue(QUEUED)
+ mockUseChat.mockReturnValue({
+ messages: [{ id: 'message-1', role: 'user', content: 'Earlier' }],
+ messageQueue: [QUEUED],
+ resources: [],
+ resolvedChatId: 'chat-a',
+ isChatHistoryPending: false,
+ sendMessage: mocks.send,
+ editQueuedMessage: mocks.editQueued,
+ })
+ mockMothershipChat.mockImplementation(({ composer }: { composer: ReactNode }) => composer)
+ queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ await act(async () =>
+ root.render(
+
+
+
+
+
+ )
+ )
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ queryClient.clear()
+})
+
+function field() {
+ const input = container.querySelector('[aria-label="Ask Sim"]')
+ if (!input) throw new Error('The composer has not rendered')
+ return input
+}
+
+function typeInto(input: HTMLTextAreaElement, text: string) {
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
+ setter?.call(input, text)
+ input.setSelectionRange(text.length, text.length)
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+}
+
+function savedDraft() {
+ return useMothershipDraftsStore.getState().drafts['reader:organization:organization-a:chat-a']
+}
+
+it('a keystroke reaches the draft without re-rendering the thread', async () => {
+ const rendersBefore = mockMothershipChat.mock.calls.length
+ const chatRendersBefore = mockUseChat.mock.calls.length
+ await act(async () => typeInto(field(), 'a'))
+ await act(async () => typeInto(field(), 'ab'))
+ await act(async () => typeInto(field(), 'abc'))
+ expect(field().value).toBe('abc')
+ expect(savedDraft()?.text).toBe('abc')
+ expect(mockMothershipChat.mock.calls.length).toBe(rendersBefore)
+ expect(mockUseChat.mock.calls.length).toBe(chatRendersBefore)
+})
+
+it('recalling the queued tail brings back its text, chips, attachments, and focus', async () => {
+ const input = field()
+ await act(async () =>
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
+ )
+ expect(mocks.editQueued).toHaveBeenCalledExactlyOnceWith('queued-1')
+ expect(field().value).toBe(QUEUED_AS_EDITED)
+ expect(document.activeElement).toBe(field())
+ expect(savedDraft()?.text).toBe(QUEUED.content)
+ expect(savedDraft()?.contexts).toEqual(QUEUED.contexts)
+ expect(container.textContent).toContain('notes.pdf')
+ await act(async () =>
+ container.querySelector('button[aria-label="Send"]')?.click()
+ )
+ expect(mocks.send).toHaveBeenCalledExactlyOnceWith(
+ QUEUED.content,
+ [expect.objectContaining({ key: 'uploads/file-1', filename: 'notes.pdf' })],
+ QUEUED.contexts,
+ expect.objectContaining({ requestMode: 'agent' })
+ )
+ expect(field().value).toBe('')
+ expect(savedDraft()).toBeUndefined()
+})
+
+it('the thread restoring a queued message over a typed draft replaces it', async () => {
+ await act(async () => typeInto(field(), 'half a thought'))
+ const { onEditQueuedMessage } = mockMothershipChat.mock.lastCall?.[0] ?? {}
+ await act(async () => onEditQueuedMessage('queued-1'))
+ expect(field().value).toBe(QUEUED_AS_EDITED)
+ expect(savedDraft()?.contexts).toEqual(QUEUED.contexts)
+ expect(document.activeElement).toBe(field())
+})
+
+it('a draft cleared from outside empties the editor and its chips', async () => {
+ await act(async () => typeInto(field(), 'stale question'))
+ await act(async () =>
+ useMothershipDraftsStore.getState().clearDraft('reader:organization:organization-a:chat-a')
+ )
+ expect(field().value).toBe('')
+ expect(savedDraft()).toBeUndefined()
+})
diff --git a/apps/sim/app/o/[organizationId]/components/organization-conversation.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-conversation.test.tsx
index da1ebf280b7..96d82750eec 100644
--- a/apps/sim/app/o/[organizationId]/components/organization-conversation.test.tsx
+++ b/apps/sim/app/o/[organizationId]/components/organization-conversation.test.tsx
@@ -1,15 +1,27 @@
/** @vitest-environment jsdom */
-import { act, type ComponentProps, type ReactNode } from 'react'
+import { act, type ReactNode } from 'react'
import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock'
import {
createMockDeploymentShape,
deploymentShapeMock,
deploymentShapeMockFns,
} from '@sim/testing/mocks/deployment-shape.mock'
+import {
+ featureFlagsProviderMock,
+ featureFlagsProviderMockFns,
+} from '@sim/testing/mocks/feature-flags-provider.mock'
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
import { kbConnectorsQueriesMock } from '@sim/testing/mocks/kb-connectors-queries.mock'
import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
+import {
+ chatResourcePanelMock,
+ mothershipChatMock,
+ mothershipChatsQueriesMock,
+ organizationConversationMockFns,
+ useChatMock,
+ useResourcePanelMock,
+} from '@sim/testing/mocks/organization-conversation.mock'
import {
organizationProviderMock,
organizationProviderMockFns,
@@ -25,15 +37,11 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AuthorizedAppsPage } from '@/lib/api/contracts/user'
-import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
+import { readDraft, useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
const mocks = vi.hoisted(() => ({
plan: false,
- resourcePanel: vi.fn(),
- chat: vi.fn(),
composer: vi.fn(),
- renderer: vi.fn(),
- markRead: vi.fn(),
send: vi.fn(),
apiKeys: vi.fn(),
authorizedApps: vi.fn(),
@@ -43,59 +51,44 @@ const mocks = vi.hoisted(() => ({
selectResource: vi.fn(),
activeResource: null as string | null,
}))
-vi.mock('@/app/workspace/[workspaceId]/providers/feature-flags-provider', () => ({
- useFeatureFlag: (name: string) => (name === 'mothership-plan-mode' ? mocks.plan : false),
-}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/providers/feature-flags-provider',
+ () => featureFlagsProviderMock
+)
vi.mock('@/lib/core/config/deployment-shape', () => deploymentShapeMock)
vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
vi.mock('next/navigation', () => nextNavigationMock)
vi.mock('@tanstack/react-query', () => reactQueryMock)
-vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-resource-panel', () => ({
- useResourcePanelController: () => ({
- onResourceEvent: undefined,
- activeResourceState: undefined,
- activeResourceParam: mocks.activeResource,
- setActiveResourceUrl: mocks.selectResource,
- }),
- useChatResourcePanel: () => ({
- isResourceCollapsed: true,
- addResourceFromUser: mocks.addResource,
- prepareResourceViewForAgentTurn: vi.fn(),
- }),
-}))
-vi.mock('@/app/workspace/[workspaceId]/home/components/chat-resource-panel', () => ({
- ChatResourcePanel: mocks.resourcePanel,
-}))
+vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-resource-panel', () => useResourcePanelMock)
+vi.mock(
+ '@/app/workspace/[workspaceId]/home/components/chat-resource-panel',
+ () => chatResourcePanelMock
+)
vi.mock('@/lib/auth/client', () => authClientMock)
vi.mock('@/lib/uploads/client/session-upload', () => ({ uploadInternalFileSession: mocks.upload }))
-vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => organizationProviderMock)
-vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-chat', () => ({
- getMothershipUseChatOptions: (options: object) => ({ ...options, mothership: true }),
- useChat: mocks.chat,
-}))
-vi.mock('@/hooks/queries/mothership-chats', () => ({
- useMarkMothershipChatRead: () => ({ mutate: mocks.markRead }),
-}))
-vi.mock('@/app/o/[organizationId]/home/components/composer', () => ({ Composer: mocks.composer }))
-vi.mock('@/app/workspace/[workspaceId]/home/components/suggested-actions', () => ({
- SuggestedActions: ({ onSelectPrompt }: { onSelectPrompt: (prompt: string) => void }) => (
- onSelectPrompt('Create a CRM with sample data.')}>
- Suggested actions
-
- ),
+vi.mock('@/lib/organizations/provider', () => organizationProviderMock)
+vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-chat', () => useChatMock)
+vi.mock('@/hooks/queries/mothership-chats', () => mothershipChatsQueriesMock)
+vi.mock('@/platform/app/(shell)/_components/conversation/page-composer', () => ({
+ PageComposer: mocks.composer,
}))
vi.mock('@/hooks/queries/kb/connectors', () => kbConnectorsQueriesMock)
vi.mock('@/hooks/queries/api-keys', () => ({ useApiKeys: mocks.apiKeys }))
vi.mock('@/hooks/queries/oauth-provider', () => ({ useAuthorizedApps: mocks.authorizedApps }))
-vi.mock('@/app/workspace/[workspaceId]/home/components/mothership-chat', () => ({
- MothershipChat: mocks.renderer,
-}))
+vi.mock('@/app/workspace/[workspaceId]/home/components/mothership-chat', () => mothershipChatMock)
-import { OrganizationConversation } from '@/app/o/[organizationId]/components/organization-conversation'
-import type { Composer } from '@/app/o/[organizationId]/home/components/composer'
+import type { ConversationComposerProps } from '@/app/o/[organizationId]/home/components/composer'
+import { PageConversation } from '@/platform/app/(shell)/_components/conversation/page-conversation'
+
+const {
+ mockMothershipChat,
+ mockUseChat,
+ mockUseChatResourcePanel,
+ mockUseResourcePanelController,
+} = organizationConversationMockFns
const mockSession = authClientMockFns.mockUseSession
-const mockContext = organizationProviderMockFns.mockUseOrganizationContext
+const mockContext = organizationProviderMockFns.mockUseOrganization
const deploymentShape = () => createMockDeploymentShape()
deploymentShapeMockFns.mockUseDeploymentShape.mockImplementation(deploymentShape)
deploymentShapeMockFns.mockGetDeploymentShape.mockImplementation(deploymentShape)
@@ -107,9 +100,22 @@ beforeEach(() => {
reactQueryMockFns.mockUseMutation.mockReturnValue(createMutationResultMock())
useMothershipDraftsStore.setState({ drafts: {} })
mocks.plan = false
+ featureFlagsProviderMockFns.mockUseFeatureFlag.mockImplementation(
+ (name: string) => name === 'mothership-plan-mode' && mocks.plan
+ )
mocks.activeResource = null
mockSession.mockReturnValue({ data: { user: { id: 'reader' } } })
- mocks.resourcePanel.mockImplementation(({ children }: { children: ReactNode }) => children)
+ mockUseResourcePanelController.mockImplementation(() => ({
+ onResourceEvent: undefined,
+ activeResourceState: undefined,
+ activeResourceParam: mocks.activeResource,
+ setActiveResourceUrl: mocks.selectResource,
+ }))
+ mockUseChatResourcePanel.mockImplementation(() => ({
+ isResourceCollapsed: true,
+ addResourceFromUser: mocks.addResource,
+ prepareResourceViewForAgentTurn: vi.fn(),
+ }))
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
vi.stubGlobal(
'URL',
@@ -128,7 +134,7 @@ beforeEach(() => {
})
mocks.apiKeys.mockReturnValue({ data: { personalKeys: [] } })
mockAuthorizedApps([{ apps: [], nextCursor: null }])
- mocks.chat.mockReturnValue({
+ mockUseChat.mockReturnValue({
messages: [],
resources: [],
isChatHistoryPending: true,
@@ -136,7 +142,7 @@ beforeEach(() => {
})
mocks.composer.mockReturnValue(Question composer
)
- mocks.renderer.mockReturnValue(Chat history
)
+ mockMothershipChat.mockReturnValue(Chat history
)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
@@ -152,12 +158,24 @@ function renderHome(element: ReactNode, searchParams = '') {
)
}
-function composerProps(): ComponentProps {
+function composerProps(): ConversationComposerProps {
const call = mocks.composer.mock.lastCall
if (!call) throw new Error('Composer has not rendered')
return call[0]
}
+function draftText() {
+ return (
+ readDraft(useMothershipDraftsStore.getState().drafts, composerProps().draftKeys)?.text ?? ''
+ )
+}
+
+function writeDraft(text: string) {
+ const store = useMothershipDraftsStore.getState()
+ const [key] = composerProps().draftKeys
+ store.setDraft(key, { ...store.drafts[key], text })
+}
+
async function attachDraftImage() {
const files = [new File(['image'], 'draft.png', { type: 'image/png' })]
await act(async () =>
@@ -183,26 +201,26 @@ function mockAuthorizedApps(
describe('organization home', () => {
it('isolates conversation state when switching cached chats', async () => {
- mocks.chat.mockReturnValue({
+ mockUseChat.mockReturnValue({
resources: [],
messages: [],
isChatHistoryPending: false,
sendMessage: mocks.send,
})
- mocks.renderer.mockImplementation(({ composer }: { composer: ReactNode }) => composer)
+ mockMothershipChat.mockImplementation(({ composer }: { composer: ReactNode }) => composer)
await act(async () =>
- renderHome( )
+ renderHome( )
)
- await act(async () => composerProps().onChange('A draft for chat A'))
+ await act(async () => writeDraft('A draft for chat A'))
await act(async () =>
- renderHome( )
+ renderHome( )
)
- expect(composerProps().value).toBe('A draft for chat A')
+ expect(draftText()).toBe('A draft for chat A')
await act(async () =>
- renderHome( )
+ renderHome( )
)
- expect(composerProps().value).toBe('')
- expect(mocks.chat).toHaveBeenLastCalledWith(
+ expect(draftText()).toBe('')
+ expect(mockUseChat).toHaveBeenLastCalledWith(
{ organizationId: 'organization-a' },
'chat-b',
expect.objectContaining({
@@ -214,33 +232,33 @@ describe('organization home', () => {
})
it('isolates saved drafts by user, organization, and conversation', async () => {
- mocks.renderer.mockImplementation(({ composer }: { composer: ReactNode }) => composer)
+ mockMothershipChat.mockImplementation(({ composer }: { composer: ReactNode }) => composer)
await act(async () =>
- renderHome( )
+ renderHome( )
)
await attachDraftImage()
- await act(async () => composerProps().onChange('Chat A follow-up'))
+ await act(async () => writeDraft('Chat A follow-up'))
await act(async () =>
- renderHome( )
+ renderHome( )
)
- expect(composerProps().value).toBe('')
+ expect(draftText()).toBe('')
await act(async () =>
- renderHome( )
+ renderHome( )
)
- expect(composerProps().value).toBe('Chat A follow-up')
+ expect(draftText()).toBe('Chat A follow-up')
expect(composerProps().files.attachedFiles[0]?.key).toBe('image-key')
mockSession.mockReturnValue({ data: { user: { id: 'other-user' } } })
await act(async () =>
- renderHome( )
+ renderHome( )
)
- expect(composerProps().value).toBe('')
+ expect(draftText()).toBe('')
mockSession.mockReturnValue({ data: { user: { id: 'reader' } } })
mockContext.mockReturnValue({
...toRecord(mockContext()),
organization: { id: 'other-org', name: 'Other' },
})
await act(async () =>
- renderHome( )
+ renderHome( )
)
- expect(composerProps().value).toBe('')
+ expect(draftText()).toBe('')
})
diff --git a/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx b/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx
deleted file mode 100644
index 38778bf511e..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx
+++ /dev/null
@@ -1,620 +0,0 @@
-'use client'
-
-import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
-import { ChipModal, ChipModalHeader, cn, toast } from '@sim/emcn'
-import { getErrorMessage } from '@sim/utils/errors'
-import { toRecord } from '@sim/utils/object'
-import { useQueryClient } from '@tanstack/react-query'
-import { createSerializer, useQueryStates } from 'nuqs'
-import { AssistantActionReviewProvider } from '@/components/assistant-actions'
-import { requestJson } from '@/lib/api/client/request'
-import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
-import { getWorkspaceHostContextContract } from '@/lib/api/contracts/workspaces'
-import { useSession } from '@/lib/auth/client'
-import { BrowserStorage, MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
-import {
- getMothershipAttachmentPreviewUrl,
- getMothershipAttachmentUrl,
-} from '@/lib/mothership/chat/attachment-preview'
-import { createSearchResource } from '@/lib/mothership/resources/search'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { isWorkspaceResourceKind, workspaceResourcePath } from '@/lib/resources'
-import { assertMultiFileUploadAdmission } from '@/lib/uploads/client/admission'
-import {
- ASSISTANT_IMAGE_MAX_BYTES,
- ASSISTANT_IMAGE_MAX_COUNT,
- ASSISTANT_IMAGE_MAX_TOTAL_BYTES,
- isAssistantImageType,
-} from '@/lib/uploads/shared/assistant-images'
-import { OrganizationLanding } from '@/app/o/[organizationId]/components/organization-landing'
-import { Composer } from '@/app/o/[organizationId]/home/components/composer'
-import { GetStarted } from '@/app/o/[organizationId]/home/components/get-started'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import {
- organizationSearchParsers,
- organizationSearchUrlKeys,
-} from '@/app/o/[organizationId]/search/search-params'
-import { ChatResourcePanel } from '@/app/workspace/[workspaceId]/home/components/chat-resource-panel'
-import { ComposerScrollArea } from '@/app/workspace/[workspaceId]/home/components/composer-scroll-area'
-import { useSearchHistoryActions } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-history-context'
-import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection'
-import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat'
-import { SourcesResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/sources-resource-content'
-import { SuggestedActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions'
-import type { ModelSelectorOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-selector'
-import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
-import {
- getMothershipUseChatOptions,
- useChat,
-} from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
-import {
- useChatResourcePanel,
- useResourcePanelController,
-} from '@/app/workspace/[workspaceId]/home/hooks/use-resource-panel'
-import { useRestoredChatEntry } from '@/app/workspace/[workspaceId]/home/hooks/use-restored-chat-entry'
-import { fetchWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
-import {
- resourceParam,
- searchFilterParsers,
- searchFiltersFromParams,
-} from '@/app/workspace/[workspaceId]/home/search-params'
-import type {
- ChatRequestMode,
- FileAttachmentForApi,
- WorkspaceResourceRef,
-} from '@/app/workspace/[workspaceId]/home/types'
-import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
-import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
-import { mentionifyIntegrations } from '@/blocks/integration-matcher'
-import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
-import { type PrepareChatSubmission, useComposerSubmission } from '@/hooks/use-composer-submission'
-import { useHydrated } from '@/hooks/use-hydrated'
-import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
-import type { ChatContext } from '@/stores/panel'
-
-export interface OrganizationSummaryRequest {
- id: string
- message: string
- assistantSearch: WorkspaceSearchFilters
-}
-
-const serializeSearchConversation = createSerializer({
- ...organizationSearchParsers,
- ...searchFilterParsers,
- [resourceParam.key]: resourceParam.parser,
-})
-
-interface OrganizationConversationProps {
- active?: boolean
- summaryRequest?: OrganizationSummaryRequest
- onSummaryConsumed?: (id: string) => void
- userName?: string
- chatId?: string
- requestMode: ChatRequestMode
- header?: ReactNode
- panelOptions?: {
- header: ReactNode
- attachments?: ReactNode
- resourceActions?: ReactNode
- toolbarActions?: ReactNode
- modelSelectorOptions?: ModelSelectorOptions
- onChatIdResolved: (chatId: string) => void
- onNavigateToChat: (chatId: string) => void
- prepareSubmission: PrepareChatSubmission
- onSubmitted: () => void
- draftNamespace: string
- }
-}
-
-/** Shared conversation surface; the destination fixes its intent before the first turn. */
-export function OrganizationConversation(props: OrganizationConversationProps) {
- const { organization, searchAccess, canBuild, mothershipAvailable } = useOrganizationContext()
- const { data: session } = useSession()
- const isClient = useHydrated()
- const isRestoredChatEntry = useRestoredChatEntry({ chatId: props.chatId })
- if (
- !mothershipAvailable ||
- (props.requestMode === 'assistant' ? !searchAccess.memberScoped : !canBuild)
- )
- return null
- if (isRestoredChatEntry || !isClient || !session?.user?.id)
- return
- return (
-
- )
-}
-
-function OrganizationConversationContent({
- userName,
- chatId,
- requestMode: destinationMode,
- header,
- active = true,
- summaryRequest,
- onSummaryConsumed,
- panelOptions,
-}: OrganizationConversationProps) {
- const { organization, canBuild } = useOrganizationContext()
- const compact = Boolean(panelOptions)
- const { data: session } = useSession()
- const userId = session?.user?.id
- const planEnabled = useFeatureFlag('mothership-plan-mode')
- const [planning, setPlanning] = useState(false)
- const requestMode =
- destinationMode === 'agent' && !chatId && planEnabled && planning ? 'plan' : destinationMode
- const [searchParams] = useQueryStates(
- { ...organizationSearchParsers, ...searchFilterParsers },
- organizationSearchUrlKeys
- )
- const { q, source, updated, from, to } = searchParams
- const controller = useResourcePanelController()
- const queryClient = useQueryClient()
- const chat = useChat({ organizationId: organization.id }, chatId, {
- ...getMothershipUseChatOptions({
- requestMode,
- getChatUrl:
- destinationMode === 'assistant'
- ? (id) =>
- serializeSearchConversation(organizationRoutes(organization.id).searchChat(id), {
- ...searchParams,
- mode: active ? 'assistant' : 'search',
- resource: controller.activeResourceState[0],
- })
- : undefined,
- onResourceEvent: controller.onResourceEvent,
- activeResourceState: controller.activeResourceState,
- }),
- ...(panelOptions
- ? { projectsDesktopTabs: false, onChatIdResolved: panelOptions.onChatIdResolved }
- : {}),
- })
- const newDraftId = !compact && destinationMode === 'assistant' ? 'search-assistant' : 'home'
- const draftPrefix = panelOptions ? `${panelOptions.draftNamespace}:` : ''
- const initialDraftKey = `${draftPrefix}${userId}:organization:${organization.id}:${chatId ?? newDraftId}`
- const draftKey = `${draftPrefix}${userId}:organization:${organization.id}:${chat.resolvedChatId ?? chatId ?? newDraftId}`
- const [legacyDraftKey] = useState(() => {
- if (panelOptions || chatId) return null
- const key = `${userId}:organization:${organization.id}:new`
- if (!useMothershipDraftsStore.getState().drafts[key]) return null
- const persisted = toRecord(BrowserStorage.getItem('organization-chat-mode', null))
- const modes = toRecord(toRecord(persisted.state).modes)
- const legacyMode =
- !canBuild || modes[`${userId}:${organization.id}`] === 'assistant' ? 'assistant' : 'agent'
- return legacyMode === destinationMode ? key : null
- })
- const savedDraft = useMothershipDraftsStore.getState().drafts
- const initialDraft =
- savedDraft[draftKey] ??
- savedDraft[initialDraftKey] ??
- (legacyDraftKey ? savedDraft[legacyDraftKey] : undefined)
- const draft = useMothershipDraftsStore(
- (state) =>
- (
- state.drafts[draftKey] ??
- state.drafts[initialDraftKey] ??
- (legacyDraftKey ? state.drafts[legacyDraftKey] : undefined)
- )?.text ?? ''
- )
- const setDraft = useCallback(
- (text: string, contexts?: ChatContext[]) => {
- const store = useMothershipDraftsStore.getState()
- store.setDraft(draftKey, { ...store.drafts[draftKey], text, contexts })
- },
- [draftKey]
- )
- const [restoredContexts, setRestoredContexts] = useState(
- () => initialDraft?.contexts ?? []
- )
- useEffect(() => {
- const store = useMothershipDraftsStore.getState()
- if (legacyDraftKey) store.migrateDraft(legacyDraftKey, initialDraftKey)
- store.migrateDraft(initialDraftKey, draftKey)
- }, [initialDraftKey, draftKey, legacyDraftKey])
- const hasChat = Boolean(chatId || chat.messages.length)
- const assistantSearchLevel = 'fast'
- const panel = useChatResourcePanel(chat, controller, userId)
- const addResource = panel.addResourceFromUser
- /** Restore an explicitly selected results tab before the first turn; closing it clears the URL. */
- useEffect(() => {
- if (compact || hasChat || !q.trim()) return
- const resource = createSearchResource({
- scope: { kind: 'organization', organizationId: organization.id },
- query: q.trim(),
- filters: searchFiltersFromParams({ source, updated, from, to }, Date.now()),
- })
- if (
- controller.activeResourceParam !== resource.id ||
- chat.resources.some((entry) => entry.id === resource.id)
- )
- return
- addResource(resource)
- }, [
- hasChat,
- q,
- source,
- updated,
- from,
- to,
- organization.id,
- controller.activeResourceParam,
- chat.resources,
- addResource,
- compact,
- ])
- const selectResource = async (ref: WorkspaceResourceRef) => {
- if (!ref.workspaceId) {
- toast.error('This resource has no workspace address.')
- return
- }
- const destination = panelOptions ? window.open('about:blank', '_blank') : null
- if (panelOptions && !destination) {
- toast.error('Allow pop-ups to open this resource in Sim.')
- return
- }
- if (destination) destination.opener = null
- try {
- const host = await requestJson(getWorkspaceHostContextContract, {
- params: { id: ref.workspaceId },
- })
- if (host.hostOrganizationId !== organization.id) {
- destination?.close()
- toast.error('This resource is outside this organization.')
- return
- }
- const resource = await fetchWorkspaceResourceRef(queryClient, ref.workspaceId, ref)
- if (!resource) {
- destination?.close()
- toast.error(`Couldn't find "${ref.title}" in its workspace`)
- return
- }
- if (destination) {
- const kind =
- resource.type === 'knowledgebase'
- ? 'knowledge'
- : resource.type === 'filefolder'
- ? 'folder'
- : resource.type
- destination.location.replace(
- isWorkspaceResourceKind(kind)
- ? workspaceResourcePath(ref.workspaceId, kind, resource.id)
- : `/workspace/${encodeURIComponent(ref.workspaceId)}/home?resource=${encodeURIComponent(resource.id)}`
- )
- } else addResource({ ...resource, workspaceId: ref.workspaceId })
- } catch {
- destination?.close()
- toast.error(`Couldn't open "${ref.title}". Check your access and try again.`)
- }
- }
- const fileDropTarget = useRef(null)
- const submission = useComposerSubmission({ mode: requestMode })
- const files = useFileAttachments({
- fileDropTarget: compact ? fileDropTarget : undefined,
- disabled: submission.pending,
- userId: session?.user?.id,
- organizationId: organization.id,
- requestMode,
- initialAttachments: initialDraft?.fileAttachments,
- })
- useEffect(() => {
- const store = useMothershipDraftsStore.getState()
- const fileAttachments = files.attachedFiles.flatMap((file) =>
- !file.uploading && file.key
- ? [
- {
- id: file.id,
- key: file.key,
- filename: file.name,
- media_type: file.type,
- size: file.size,
- path: file.path,
- },
- ]
- : []
- )
- store.setDraft(draftKey, {
- ...(store.drafts[draftKey] ?? { text: '' }),
- fileAttachments,
- })
- }, [draftKey, files.attachedFiles])
- useEffect(() => {
- if (chat.error) toast.error(chat.error)
- }, [chat.error])
- const { recordQuery } = useSearchHistoryActions()
- const { sendMessage } = chat
- const { mutate: markRead } = useMarkMothershipChatRead({ organizationId: organization.id })
- const firstName = userName?.split(' ')[0] ?? ''
-
- useEffect(() => {
- if (active && chat.resolvedChatId && !chat.isSending && !chat.isReconnecting)
- markRead(chat.resolvedChatId)
- }, [active, chat.resolvedChatId, chat.isSending, chat.isReconnecting, markRead])
-
- const consumedSummary = useRef(null)
- useEffect(() => {
- if (!active || !summaryRequest || consumedSummary.current === summaryRequest.id) return
- consumedSummary.current = summaryRequest.id
- onSummaryConsumed?.(summaryRequest.id)
- void sendMessage(summaryRequest.message, undefined, undefined, {
- requestMode: 'assistant',
- assistantSearchLevel,
- assistantSearch: summaryRequest.assistantSearch,
- })
- }, [active, summaryRequest, onSummaryConsumed, sendMessage])
-
- useEffect(() => {
- if (compact || chatId) return
- const handoff = MothershipHandoffStorage.consume(
- { organizationId: organization.id },
- undefined,
- requestMode
- )
- if (handoff && (handoff.message || handoff.fileAttachments?.length)) {
- void sendMessage(handoff.message ?? '', handoff.fileAttachments, handoff.contexts, {
- requestMode,
- ...(handoff.resumeUserMessageId
- ? { resumeUserMessageId: handoff.resumeUserMessageId }
- : {}),
- ...(requestMode === 'assistant'
- ? {
- assistantSearchLevel,
- }
- : {}),
- ...(handoff.assistantSearch ? { assistantSearch: handoff.assistantSearch } : {}),
- })
- }
- }, [compact, chatId, organization.id, requestMode, sendMessage, assistantSearchLevel])
-
- const send = (
- message: string,
- fileAttachments?: FileAttachmentForApi[],
- contexts?: ChatContext[],
- assistantSearch?: WorkspaceSearchFilters
- ) => {
- if (requestMode !== 'assistant' && !canBuild) return
- if (requestMode === 'assistant') recordQuery(message)
- if (requestMode !== 'assistant' && !panelOptions) panel.prepareResourceViewForAgentTurn()
- void sendMessage(message, fileAttachments, contexts, {
- requestMode,
- ...(requestMode === 'assistant' ? { assistantSearchLevel } : {}),
- ...(assistantSearch ? { assistantSearch } : {}),
- })
- }
- const submit = (text: string, contexts?: ChatContext[]) => {
- if (files.attachedFiles.some((file) => file.uploading)) return false
- if (requestMode === 'assistant') {
- if (files.attachedFiles.some((file) => !isAssistantImageType(file.type))) {
- toast.error('Remove unsupported files to use Assistant.')
- return false
- }
- try {
- assertMultiFileUploadAdmission(files.attachedFiles, {
- maxFiles: ASSISTANT_IMAGE_MAX_COUNT,
- maxFileBytes: ASSISTANT_IMAGE_MAX_BYTES,
- maxTotalBytes: ASSISTANT_IMAGE_MAX_TOTAL_BYTES,
- })
- } catch (error) {
- toast.error(getErrorMessage(error, 'Check the attached images before sending.'))
- return false
- }
- }
- const attachments: FileAttachmentForApi[] = files.attachedFiles.flatMap((file) =>
- file.key
- ? [
- {
- id: file.id,
- key: file.key,
- filename: file.name,
- media_type: file.type,
- size: file.size,
- path: file.path,
- },
- ]
- : []
- )
- const commit = ({
- message,
- contexts: preparedContexts,
- }: {
- message: string
- contexts?: ChatContext[]
- }) => {
- if (!message && !attachments.length) return false
- useMothershipDraftsStore.getState().clearDraft(draftKey)
- send(message, attachments.length ? attachments : undefined, preparedContexts)
- panelOptions?.onSubmitted()
- setRestoredContexts([])
- files.clearAttachedFiles()
- }
- return panelOptions
- ? panelOptions.prepareSubmission(text.trim(), contexts, commit)
- : commit({ message: text.trim(), contexts })
- }
-
- const editQueuedMessage = (id: string) => {
- const queued = chat.editQueuedMessage(id)
- if (queued) {
- setDraft(queued.content, queued.contexts)
- setRestoredContexts(queued.contexts ?? [])
- files.restoreAttachedFiles(
- (queued.fileAttachments ?? []).map((file) => ({
- id: file.id,
- key: file.key,
- name: file.filename,
- type: file.media_type,
- size: file.size,
- path:
- file.path ||
- getMothershipAttachmentPreviewUrl(file) ||
- getMothershipAttachmentUrl(file),
- previewUrl: getMothershipAttachmentPreviewUrl(file),
- uploading: false,
- }))
- )
- }
- return queued
- }
-
- const composer = (
- setPlanning((value) => !value)
- : undefined
- }
- value={draft}
- restoredContexts={restoredContexts}
- messages={chat.messages}
- onEditQueuedTail={() => {
- const tail = chat.messageQueue.at(-1)
- return tail ? Boolean(editQueuedMessage(tail.id)) : false
- }}
- files={files}
- isInitialView={!hasChat}
- isSending={chat.isSending || chat.isReconnecting}
- onChange={setDraft}
- onSubmit={submit}
- onSendQueuedHead={() => {
- void chat.sendNow()
- }}
- onStop={() => {
- void chat.stopGeneration()
- }}
- />
- )
-
- const content = (
-
- {hasChat ? (
-
-
- addResource({
- type: 'sources',
- id: 'cited-sources',
- title: 'Sources',
- sources: { messageId, ...(requestId ? { requestId } : {}) },
- })
- }
- SearchConnectionComponent={SearchIntegrationConnection}
- messages={chat.messages}
- isSending={chat.isSending}
- isReconnecting={chat.isReconnecting}
- isLoading={Boolean(chatId) && !chat.messages.length && chat.isChatHistoryPending}
- onSubmit={send}
- onStopGeneration={() => {
- void chat.stopGeneration()
- }}
- messageQueue={chat.messageQueue}
- editingQueuedId={chat.editingQueuedId}
- dispatchingHeadId={chat.dispatchingHeadId}
- onRemoveQueuedMessage={chat.removeFromQueue}
- onSendQueuedMessage={chat.sendNow}
- onEditQueuedMessage={editQueuedMessage}
- onCancelQueueEdit={chat.cancelQueueEdit}
- userId={session?.user?.id}
- chatId={chat.resolvedChatId}
- composer={composer}
- onWorkspaceResourceSelect={requestMode !== 'assistant' ? selectResource : undefined}
- initialScrollBlocked={!panelOptions && panel.initialScrollBlocked}
- />
-
- ) : panelOptions ? (
- <>
-
-
How can I help?
-
-
{composer}
- >
- ) : (
-
- {composer}
-
- {requestMode !== 'assistant' ? (
- setDraft(mentionifyIntegrations(prompt))}
- />
- ) : (
-
- )}
-
-
- )}
-
- )
- if (panelOptions) {
- const sourceResource = chat.resources.find(
- (resource) => resource.type === 'sources' && resource.id === controller.activeResourceParam
- )
- return (
-
- {panelOptions.header}
- {content}
- {
- if (!open) controller.activeResourceState[1](null)
- }}
- srTitle='Cited sources'
- size='sm'
- >
- controller.activeResourceState[1](null)}>
- Cited sources
-
- {sourceResource && (
-
-
-
- )}
-
-
- )
- }
- return (
- send(message, undefined, undefined, filters)}
- >
- {content}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-landing.tsx b/apps/sim/app/o/[organizationId]/components/organization-landing.tsx
deleted file mode 100644
index d643dd476b8..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-landing.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { ReactNode } from 'react'
-import { cn, pageHeadingClassName } from '@sim/emcn'
-
-interface OrganizationLandingProps {
- heading: string
- children: ReactNode
-}
-
-/** Keeps the Home and Search composers centered independently of the shortcuts below them. */
-export function OrganizationLanding({ heading, children }: OrganizationLandingProps) {
- return (
-
-
-
{heading}
-
{children}
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/constants.ts b/apps/sim/app/o/[organizationId]/components/organization-page/constants.ts
deleted file mode 100644
index 7a75d97ce48..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-page/constants.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-/**
- * The home surface's reading column, so every organization page shares its width. Kept out of
- * the client component module so server-rendered loading views receive the class string itself.
- */
-export const PAGE_COLUMN_CLASS = 'mx-auto w-full max-w-chat px-6'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/index.ts b/apps/sim/app/o/[organizationId]/components/organization-page/index.ts
deleted file mode 100644
index eac6781b000..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-page/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { PAGE_COLUMN_CLASS } from '@/app/o/[organizationId]/components/organization-page/constants'
-export { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page/organization-page'
-export { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx
deleted file mode 100644
index 0cd6a8e495d..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-'use client'
-
-import { type ReactNode, useRef, useState } from 'react'
-import {
- Button,
- Chip,
- ChipInput,
- cn,
- scrollFadeAttributes,
- scrollFadeClass,
- scrollFadeXClass,
- useScrollEdges,
-} from '@sim/emcn'
-import { Search, X } from '@sim/emcn/icons'
-import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
-import { PAGE_COLUMN_CLASS } from '@/app/o/[organizationId]/components/organization-page/constants'
-import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters'
-import {
- SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
- SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
-} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
-
-interface OrganizationPageTab {
- id: string
- label: string
-}
-
-interface OrganizationPageProps {
- title: string
- description?: string
- /** Header tabs; the first is the default. Omit for a page with one view. */
- tabs?: readonly OrganizationPageTab[]
- /** The page's primary action, a chip. Omit for a page without one. */
- action?: ReactNode
- /** Keep the search field visible on pages whose main content is a searchable list. */
- searchMode?: 'collapsible' | 'expanded'
- searchPlaceholder?: string
- children?: ReactNode
-}
-
-/**
- * The shell every organization page renders into: the top bar the workspace pages
- * wear, then a fixed page header — title, description, tabs, search, and the
- * optional action — over a scroll region that fades at both edges the way the
- * sidebar does. Pages supply their content and logic; nothing else.
- *
- * The shell paints at once and never waits on data: a page renders each piece —
- * a tab, a list, a count — the moment it is known and nothing before, with no
- * skeleton standing in for it. Pass `tabs` only once they are known; the row
- * simply gains them.
- */
-export function OrganizationPage({
- title,
- description,
- tabs,
- action,
- searchMode = 'collapsible',
- searchPlaceholder = 'Search',
- children,
-}: OrganizationPageProps) {
- const scrollContainerRef = useRef(null)
- const scrollContentRef = useRef(null)
- const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef })
- const tabsRef = useRef(null)
- const tabEdges = useScrollEdges(tabsRef, { axis: 'x' })
-
- const { tab, search, setTab, setSearch } = useOrganizationPageFilters()
- const defaultTab = tabs?.[0]?.id
- const activeTab = tab ?? defaultTab
-
- /**
- * The field stays open while it holds text, across tab switches and reloads,
- * since the text lives in the URL; this only remembers an empty field the
- * viewer opened and has not dismissed.
- */
- const [searchOpened, setSearchOpened] = useState(false)
- const searchOpen = searchMode === 'expanded' || searchOpened || search.length > 0
-
- const closeSearch = () => {
- setSearch('')
- setSearchOpened(false)
- }
-
- return (
-
- {/* Reserved even while empty so the page header sits where the workspace's does. */}
-
-
-
-
-
{title}
- {description &&
{description}
}
-
-
- {/* The row yields to the controls beside it and scrolls sideways under a fade
- once it can no longer fit; the scrollbar itself never shows. */}
-
- {tabs?.map((item) => {
- const active = item.id === activeTab
- return (
- setTab(item.id === defaultTab ? null : item.id)}
- className='min-w-[44px] shrink-0 text-center'
- >
- {item.label}
-
- )
- })}
-
-
- {searchOpen ? (
- setSearch(event.target.value)}
- onKeyDown={(event) => {
- if (event.key === 'Escape') closeSearch()
- }}
- endAdornment={
- (searchMode === 'collapsible' || search.length > 0) && (
-
-
-
- )
- }
- />
- ) : (
- setSearchOpened(true)} />
- )}
- {action}
-
-
-
-
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/search-params.ts b/apps/sim/app/o/[organizationId]/components/organization-page/search-params.ts
deleted file mode 100644
index 2be9ae79290..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-page/search-params.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { parseAsString } from 'nuqs/server'
-
-/**
- * Co-located, typed URL query-param definitions for an organization page's
- * header. Both are view state the page's content filters by, so a link carries
- * them and a tab switch keeps them.
- *
- * - `tab` is the active header tab. Absent, the page shows its first tab, so the
- * key only appears once the viewer leaves it.
- * - `q` is the search field's text, written raw (consumers trim on read) and
- * debounced on the way to the URL by `useDebouncedSearchSetter`.
- */
-export const organizationPageParsers = {
- tab: parseAsString,
- q: parseAsString.withDefault(''),
-} as const
-
-/** Tabs and search are filter-like view changes, not navigation: replace, and clear at the default. */
-export const organizationPageUrlKeys = {
- history: 'replace',
- clearOnDefault: true,
-} as const
diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/use-organization-page-filters.ts b/apps/sim/app/o/[organizationId]/components/organization-page/use-organization-page-filters.ts
deleted file mode 100644
index 371ae5527af..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-page/use-organization-page-filters.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useCallback } from 'react'
-import { useQueryStates } from 'nuqs'
-import {
- organizationPageParsers,
- organizationPageUrlKeys,
-} from '@/app/o/[organizationId]/components/organization-page/search-params'
-import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
-
-/**
- * The header filters of the organization page the caller sits on. The shell
- * drives them; a page's content reads `tab` and `search` to filter its list, so
- * the same criteria apply whichever tab is showing.
- */
-export function useOrganizationPageFilters() {
- const [{ tab, q }, setFilters] = useQueryStates(organizationPageParsers, organizationPageUrlKeys)
-
- const setTab = useCallback((next: string | null) => setFilters({ tab: next }), [setFilters])
- const setSearch = useDebouncedSearchSetter((value, options) => setFilters({ q: value }, options))
-
- return { tab, search: q, setTab, setSearch }
-}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx
deleted file mode 100644
index d3077103b59..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx
+++ /dev/null
@@ -1,235 +0,0 @@
-'use client'
-
-import {
- chipVariants,
- cn,
- DropdownMenuItem,
- OverflowText,
- RowActions,
- rowActionsGroupClass,
-} from '@sim/emcn'
-import { MoreHorizontal, Pin, Task } from '@sim/emcn/icons'
-import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
-import { useOrganizationChatActions } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions'
-import {
- ChatNavigationLink,
- CollapsedChatFlyoutItem,
- CollapsedSidebarMenu,
- SidebarSection,
-} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
-import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row'
-import { SidebarRowAction } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions'
-import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
-import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
-import {
- SIDEBAR_ITEM_GAP_CLASS,
- SIDEBAR_SECTION_GAP_CLASS,
-} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
-
-interface ChatRowProps {
- chat: OrganizationChat
- isCurrentRoute: boolean
- isSelected: boolean
- isMenuOpen: boolean
- onSelectChat: (chatId: string, shiftKey: boolean) => void
- onContextMenu: (e: React.MouseEvent, chatId: string) => void
- onMorePointerDown: () => void
- onMoreClick: (e: React.MouseEvent, chatId: string) => void
-}
-
-function ChatRow({
- chat,
- isCurrentRoute,
- isSelected,
- isMenuOpen,
- onSelectChat,
- onContextMenu,
- onMorePointerDown,
- onMoreClick,
-}: ChatRowProps) {
- /**
- * The trailing slot fits one glyph, and the dot wins over the pin: it reports
- * transient state (a run in progress, or an unread reply elsewhere), while pinning
- * is persistent and already conveyed by the row sorting to the top of the list.
- */
- const showStatusDot = Boolean(chat.isActive) || (!isCurrentRoute && Boolean(chat.isUnread))
-
- return (
- onContextMenu(e, chat.id)}
- onSelectChat={onSelectChat}
- >
-
-
- ) : chat.isPinned ? (
-
- ) : undefined
- }
- >
- {
- e.preventDefault()
- e.stopPropagation()
- onMoreClick(e, chat.id)
- }}
- >
-
-
-
-
- )
-}
-
-interface ChatsSectionProps {
- organizationId: string
- chats: OrganizationChat[]
- isLoading: boolean
- isCollapsed: boolean
- pathname: string | null
-}
-
-export function ChatsSection({
- organizationId,
- chats,
- isLoading,
- isCollapsed,
- pathname,
-}: ChatsSectionProps) {
- const actions = useOrganizationChatActions({ organizationId, chats })
- const { menu, hover, rename, selectedChat, selectedChats, menuOpenChatId } = actions
- const saveRename = () => {
- void rename.saveRename()
- }
-
- return (
- <>
-
- {isCollapsed ? (
-
- }
- hover={hover}
- ariaLabel='Chats'
- isEditing={rename.editingId !== null}
- >
- {isLoading ? null : chats.length === 0 ? (
- No chats yet
- ) : (
- chats.map((chat) => (
- 1 && selectedChats.has(chat.id)}
- onSelectChat={actions.handleChatClick}
- isMenuOpen={menuOpenChatId === chat.id}
- isEditing={rename.editingId === chat.id}
- editValue={rename.value}
- inputRef={rename.inputRef}
- isRenaming={rename.isSaving}
- onEditValueChange={rename.setValue}
- onEditKeyDown={rename.handleKeyDown}
- onEditBlur={saveRename}
- onContextMenu={actions.onContextMenu}
- onMorePointerDown={actions.onMorePointerDown}
- onMoreClick={actions.onMoreClick}
- />
- ))
- )}
-
-
- ) : (
-
- {!isLoading && (
- <>
- {chats.length === 0 && (
-
- No chats yet
-
- )}
- {chats.map((chat) =>
- rename.editingId === chat.id ? (
-
rename.setValue(event.target.value)}
- onKeyDown={rename.handleKeyDown}
- onBlur={saveRename}
- disabled={rename.isSaving}
- />
- ) : (
- 1 && selectedChats.has(chat.id)}
- isMenuOpen={menuOpenChatId === chat.id}
- onSelectChat={actions.handleChatClick}
- onContextMenu={actions.onContextMenu}
- onMorePointerDown={actions.onMorePointerDown}
- onMoreClick={actions.onMoreClick}
- />
- )
- )}
- >
- )}
-
- )}
-
- 0}
- selectedCount={actions.selectedCount}
- showDuplicate={false}
- />
- 0}
- onClose={actions.cancelDelete}
- onConfirm={actions.confirmDelete}
- isDeleting={actions.isDeleting}
- itemType='task'
- itemName={actions.chatsToDelete.map((chat) => chat.name)}
- />
- >
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts
deleted file mode 100644
index a2ee28fffc9..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { ChatsSection } from './chats-section'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts
deleted file mode 100644
index c7f48483337..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export { ChatsSection } from './chats-section'
-export { OrganizationFooter } from './organization-footer'
-export { OrganizationHeader } from './organization-header'
-export { WorkspacesSection } from './workspaces-section'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts
deleted file mode 100644
index 95078897371..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { OrganizationFooter } from './organization-footer'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx
deleted file mode 100644
index 125ab4202af..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-'use client'
-
-import type { ComponentProps } from 'react'
-import { useRouter } from 'next/navigation'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { SidebarFooter } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer'
-
-interface OrganizationFooterProps
- extends Omit<
- ComponentProps,
- 'accountSettingsHref' | 'onOpenAccountSettings' | 'navigationLinks'
- > {}
-
-export function OrganizationFooter(props: OrganizationFooterProps) {
- const { organization } = useOrganizationContext()
- const router = useRouter()
- const accountSettingsHref = organizationRoutes(organization.id).settingsSection('general')
-
- return (
- router.push(accountSettingsHref)}
- navigationLinks={[]}
- />
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts
deleted file mode 100644
index 7040e44e82d..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { OrganizationHeader } from './organization-header'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx
deleted file mode 100644
index 6f44c352d39..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx
+++ /dev/null
@@ -1,278 +0,0 @@
-'use client'
-
-import { useRef, useState } from 'react'
-import {
- Chip,
- ChipChevronDown,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
- OverflowText,
- Tooltip,
- toast,
-} from '@sim/emcn'
-import { PanelLeft, Send, Settings } from '@sim/emcn/icons'
-import { getErrorMessage } from '@sim/utils/errors'
-import { useRouter } from 'next/navigation'
-import { IdentityTile } from '@/components/identity-tile/identity-tile'
-import { getOrganizationSettingsHref } from '@/components/settings/navigation'
-import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link'
-import type { OrganizationSurfaceOrganization } from '@/lib/organizations/surface'
-import { LOGO_ACCEPT_ATTRIBUTE } from '@/lib/uploads/client/logo-file'
-import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal'
-import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row'
-import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
-import { holdRowFocus } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
-import { useRenameOrganization } from '@/hooks/queries/organization'
-import { useUploadOrganizationLogo } from '@/hooks/queries/organization-logo'
-
-function getOrganizationInitial(name: string): string {
- return (name.trim()[0] || 'O').toUpperCase()
-}
-
-interface OrganizationHeaderProps {
- organization: OrganizationSurfaceOrganization
- /** Organization admins may change its logo and name. */
- canEditIdentity: boolean
- canInviteMembers: boolean
- isCollapsed: boolean
- /** Expands the rail; the collapsed header is itself the expand control. */
- onExpandSidebar: () => void
-}
-
-/**
- * The top-left organization chip. Expanded, it names the organization and opens
- * its card — the mark at tile size, the name, how many people belong, and the way
- * into its settings; collapsed, it becomes the rail's expand control, swapping
- * the mark for a panel glyph on hover exactly as the workspace header does. The
- * mark is the organization's uploaded logo or its initial on the neutral tile.
- * Admins change the logo and rename the organization from the card.
- */
-export function OrganizationHeader({
- organization,
- canEditIdentity,
- canInviteMembers,
- isCollapsed,
- onExpandSidebar,
-}: OrganizationHeaderProps) {
- const fileInputRef = useRef(null)
- const nameInputRef = useRef(null)
- const router = useRouter()
- const { mutate: uploadLogo, isPending: isUploadingLogo } = useUploadOrganizationLogo(
- organization.id
- )
- const renameOrganization = useRenameOrganization()
- const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
- const [isMenuOpen, setIsMenuOpen] = useState(false)
- const [draftName, setDraftName] = useState(null)
- /**
- * The name just saved, shown until the refreshed server prop replaces the one
- * it was saved over; without it the old name flashes back in between.
- */
- const [savedName, setSavedName] = useState<{ name: string; replaces: string } | null>(null)
- if (savedName && savedName.replaces !== organization.name) setSavedName(null)
- const organizationName = savedName?.name ?? organization.name
- const initial = getOrganizationInitial(organizationName)
-
- if (isCollapsed) {
- return (
-
- }
- />
-
- )
- }
-
- const { memberCount } = organization
- const stopRenaming = () => {
- setDraftName(null)
- renameOrganization.reset()
- }
-
- const submitRename = () => {
- if (draftName === null || renameOrganization.isPending) return
- const name = draftName.trim()
- if (!name || name === organizationName) {
- stopRenaming()
- return
- }
- renameOrganization.mutate(
- { orgId: organization.id, name },
- {
- onSuccess: () => {
- setSavedName({ name, replaces: organization.name })
- stopRenaming()
- },
- onError: () => requestAnimationFrame(() => nameInputRef.current?.focus()),
- }
- )
- }
-
- const logo = (
-
- )
-
- return (
-
- {canEditIdentity && (
-
{
- const file = event.target.files?.[0]
- event.target.value = ''
- if (!file || isUploadingLogo) return
- uploadLogo(file, {
- onSuccess: () => router.refresh(),
- onError: (error) => toast.error(error.message),
- })
- }}
- />
- )}
-
{
- // The input's blur saves the draft; closing now would unmount it and hide a failure.
- if (!open && draftName !== null) return
- setIsMenuOpen(open)
- }}
- >
-
- }
- rightAdornment={ }
- >
- {organizationName}
-
-
-
-
- {canEditIdentity ? (
-
-
- {
- event.preventDefault()
- fileInputRef.current?.click()
- }}
- >
- {logo}
-
-
- {isUploadingLogo ? 'Uploading' : 'Change logo'}
-
- ) : (
- logo
- )}
-
- {draftName !== null ? (
- event.currentTarget.select()}
- onChange={(event) => {
- setDraftName(event.target.value)
- if (renameOrganization.isError) renameOrganization.reset()
- }}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault()
- submitRename()
- } else if (event.key === 'Escape') {
- event.preventDefault()
- stopRenaming()
- }
- }}
- onBlur={submitRename}
- />
- ) : canEditIdentity ? (
- {
- event.preventDefault()
- setDraftName(organizationName)
- }}
- >
-
-
- ) : (
-
- )}
- {renameOrganization.isError && (
-
- {getErrorMessage(renameOrganization.error, 'Failed to rename organization')}
-
- )}
-
- {memberCount} {memberCount === 1 ? 'member' : 'members'}
-
-
-
-
-
-
- Settings
-
-
- {canInviteMembers && (
- setIsInviteModalOpen(true)}>
-
- Invite people
-
- )}
-
-
- {isInviteModalOpen && (
-
- )}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/index.ts
deleted file mode 100644
index 48d480cdd44..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { WorkspacesSection } from './workspaces-section'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx
deleted file mode 100644
index f371d69d4a8..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx
+++ /dev/null
@@ -1,249 +0,0 @@
-'use client'
-
-import { useEffect, useState } from 'react'
-import {
- chipVariants,
- cn,
- DropdownMenuItem,
- DropdownMenuItemAction,
- OverflowText,
- RowActions,
- rowActionsGroupClass,
- toast,
-} from '@sim/emcn'
-import { MoreHorizontal, Pin } from '@sim/emcn/icons'
-import { getErrorMessage } from '@sim/utils/errors'
-import { IdentityTile } from '@/components/identity-tile/identity-tile'
-import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link'
-import { WorkspaceContextMenu } from '@/components/workspaces/workspace-context-menu'
-import { getWorkspaceInitial } from '@/lib/workspaces/initials'
-import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces'
-import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row'
-import { SidebarRowAction } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions'
-import { useFlyoutInlineRename } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename'
-import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu'
-import { useToggleWorkspacePin, useUpdateWorkspace } from '@/hooks/queries/workspace'
-import { useContextMenu } from '@/hooks/use-context-menu'
-
-const PAGE_SIZE = 5
-
-interface WorkspaceListProps {
- organizationId: string
- pathname?: string | null
- /** The rail flyout uses the same actions and ordering as the expanded list. */
- flyout?: ReturnType
-}
-
-export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceListProps) {
- const { workspaces, pinnedWorkspaceIds, isLoading } = useOrganizationWorkspaces(organizationId)
- const { mutate: togglePin } = useToggleWorkspacePin()
- const { mutateAsync: updateWorkspace } = useUpdateWorkspace()
- const menu = useContextMenu()
- const [selectedId, setSelectedId] = useState(null)
- const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
- const selectedWorkspace = workspaces.find((workspace) => workspace.id === selectedId)
- const rename = useFlyoutInlineRename({
- itemType: 'workspace',
- onSave: async (workspaceId, name) => {
- try {
- await updateWorkspace({ workspaceId, name })
- } catch (error) {
- toast.error(getErrorMessage(error, 'Failed to rename workspace'))
- throw error
- }
- },
- })
- const lockFlyout = flyout?.setLocked
- const isInteracting = menu.isOpen || rename.editingId !== null
-
- useEffect(() => {
- if (!lockFlyout || !isInteracting) return
- lockFlyout(true)
- return () => lockFlyout(false)
- }, [lockFlyout, isInteracting])
-
- const visibleWorkspaces = flyout ? workspaces : workspaces.slice(0, visibleCount)
- const hasMore = workspaces.length > visibleCount
-
- const openMenu = (event: React.MouseEvent, workspaceId: string) => {
- setSelectedId(workspaceId)
- flyout?.setLocked(true)
- menu.preventDismiss()
- menu.handleContextMenu(event)
- }
-
- return (
- <>
- {!isLoading && workspaces.length === 0 && (
- No workspaces yet
- )}
- {visibleWorkspaces.map((workspace) => {
- const href = `/workspace/${workspace.id}`
- const isActive = pathname === href || Boolean(pathname?.startsWith(`${href}/`))
- const isMenuOpen = menu.isOpen && selectedId === workspace.id
- const isPinned = pinnedWorkspaceIds.has(workspace.id)
- const label = (
- <>
-
-
- >
- )
- const onMoreClick = (event: React.MouseEvent) => {
- event.preventDefault()
- event.stopPropagation()
- if (isMenuOpen) {
- menu.closeMenu()
- return
- }
- setSelectedId(workspace.id)
- flyout?.setLocked(true)
- const rect = event.currentTarget.getBoundingClientRect()
- menu.openMenuAt({ x: rect.right, y: rect.top })
- }
-
- if (rename.editingId === workspace.id) {
- return (
-
- }
- aria-label={`Rename workspace ${workspace.name}`}
- value={rename.value}
- onChange={(event) => rename.setValue(event.target.value)}
- onKeyDown={rename.handleKeyDown}
- onBlur={() => void rename.saveRename()}
- disabled={rename.isSaving}
- />
- )
- }
-
- if (flyout) {
- return (
- {
- if (menu.isOpen || rename.editingId) event.preventDefault()
- }}
- actionIndicator={
- isPinned ? (
-
- ) : undefined
- }
- action={
- menu.preventDismiss()}
- onClick={onMoreClick}
- >
-
-
- }
- >
- openMenu(event, workspace.id)}
- >
- {label}
-
-
- )
- }
-
- return (
- openMenu(event, workspace.id)}
- >
- {label}
-
- ) : undefined
- }
- >
- menu.preventDismiss()}
- onClick={onMoreClick}
- >
-
-
-
-
- )
- })}
- {!flyout && workspaces.length > PAGE_SIZE && (
- setVisibleCount((count) => (hasMore ? count + PAGE_SIZE : PAGE_SIZE))}
- className={cn(chipVariants({ fullWidth: true }), 'text-[var(--text-muted)] text-small')}
- >
- {hasMore ? 'See more' : 'See less'}
-
- )}
- {
- if (selectedWorkspace)
- window.open(`/workspace/${selectedWorkspace.id}`, '_blank', 'noopener,noreferrer')
- }}
- onRename={() => {
- if (selectedWorkspace?.permissions === 'admin') rename.startRename(selectedWorkspace)
- }}
- isPinned={Boolean(selectedId && pinnedWorkspaceIds.has(selectedId))}
- onTogglePin={() => {
- if (selectedWorkspace) {
- togglePin(
- {
- workspaceId: selectedWorkspace.id,
- pinned: !pinnedWorkspaceIds.has(selectedWorkspace.id),
- },
- { onError: (error) => toast.error(error.message) }
- )
- }
- }}
- />
- >
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.tsx
deleted file mode 100644
index 8c11625e2a2..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { Button, cn, Tooltip } from '@sim/emcn'
-import { Plus, Workspaces } from '@sim/emcn/icons'
-import { useRouter } from 'next/navigation'
-import { WorkspaceList } from '@/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list'
-import {
- CollapsedSidebarMenu,
- SidebarSection,
-} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
-import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal'
-import { SIDEBAR_ITEM_GAP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
-import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu'
-import { useCreateWorkspace, useWorkspaceCreationPolicy } from '@/hooks/queries/workspace'
-import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
-
-interface WorkspacesSectionProps {
- organizationId: string
- isCollapsed: boolean
- pathname: string | null
-}
-
-export function WorkspacesSection({
- organizationId,
- isCollapsed,
- pathname,
-}: WorkspacesSectionProps) {
- const router = useRouter()
- const hover = useHoverMenu()
- const [isCreateOpen, setIsCreateOpen] = useState(false)
- const { data: creationPolicy } = useWorkspaceCreationPolicy()
- const { mutateAsync: createWorkspace, isPending: isCreating } = useCreateWorkspace()
- const canCreate = creationPolicy?.canCreate && creationPolicy.organizationId === organizationId
- const createDisabledReason = creationPolicy?.reason ?? 'Workspace creation is unavailable.'
-
- const openCreate = () => {
- if (!canCreate || isCreating) return
- useSettingsDirtyStore.getState().requestLeave(() => {
- hover.close()
- setIsCreateOpen(true)
- })
- }
-
- return (
- <>
-
-
-
-
-
-
-
- {canCreate ? 'New workspace' : createDisabledReason}
-
-
- )
- }
- >
- {isCollapsed ? (
-
- }
- hover={hover}
- ariaLabel='Workspaces'
- primaryAction={
- canCreate ? { label: 'New workspace', onSelect: openCreate } : undefined
- }
- >
-
-
-
- ) : (
-
-
-
- )}
-
- {
- if (!canCreate) throw new Error(createDisabledReason)
- const workspace = await createWorkspace({ name })
- setIsCreateOpen(false)
- router.push(`/workspace/${workspace.id}`)
- }}
- />
- >
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts
deleted file mode 100644
index 3dee5e2faea..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export type { OrganizationChat } from './use-organization-chats'
-export { useOrganizationChats } from './use-organization-chats'
-export { useOrganizationWorkspaces } from './use-organization-workspaces'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts
deleted file mode 100644
index 5470dfd65b9..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts
+++ /dev/null
@@ -1,225 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
-import { toast } from '@sim/emcn'
-import { getErrorMessage } from '@sim/utils/errors'
-import { useRouter } from 'next/navigation'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats'
-import { useChatSelection } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-chat-selection'
-import { useFlyoutInlineRename } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename'
-import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu'
-import {
- MothershipChatDeleteError,
- useDeleteMothershipChat,
- useDeleteMothershipChats,
- useMarkMothershipChatRead,
- useMarkMothershipChatUnread,
- useRenameMothershipChat,
- useSetMothershipChatPinned,
-} from '@/hooks/queries/mothership-chats'
-import { useContextMenu } from '@/hooks/use-context-menu'
-import { useFolderStore } from '@/stores/folders/store'
-
-interface UseOrganizationChatActionsProps {
- organizationId: string
- chats: OrganizationChat[]
-}
-
-export function useOrganizationChatActions({
- organizationId,
- chats,
-}: UseOrganizationChatActionsProps) {
- const router = useRouter()
- const owner = { organizationId }
- const deleteChat = useDeleteMothershipChat(owner)
- const deleteChats = useDeleteMothershipChats(owner)
- const isDeleting = deleteChat.isPending || deleteChats.isPending
- const { mutateAsync: renameChat } = useRenameMothershipChat(owner)
- const { mutate: pinChat } = useSetMothershipChatPinned(owner)
- const { mutate: readChat } = useMarkMothershipChatRead(owner)
- const { mutate: unreadChat } = useMarkMothershipChatUnread(owner)
- const menu = useContextMenu()
- const hover = useHoverMenu()
- const chatIds = useMemo(() => chats.map((chat) => chat.id), [chats])
- const { selectedChats, handleChatClick } = useChatSelection({ chatIds })
- const [menuSelection, setMenuSelection] = useState<{
- chatId: string
- chatIds: string[]
- } | null>(null)
- const [chatIdsToDelete, setChatIdsToDelete] = useState([])
- const deleteTargetIds = new Set(chatIdsToDelete)
- const chatsToDelete = chats.filter((chat) => deleteTargetIds.has(chat.id))
- const selectedChat =
- menuSelection?.chatIds.length === 1
- ? chats.find((chat) => chat.id === menuSelection.chatId)
- : undefined
- const rename = useFlyoutInlineRename({
- itemType: 'chat',
- onSave: async (chatId, title) => {
- try {
- await renameChat({ chatId, title })
- } catch (error) {
- toast.error(getErrorMessage(error, 'Failed to rename chat'))
- throw error
- }
- },
- })
-
- /** Keep the parent flyout open while its nested menu or rename field owns focus. */
- useEffect(() => {
- hover.setLocked(menu.isOpen || rename.editingId !== null)
- }, [menu.isOpen, rename.editingId, hover.setLocked])
-
- const captureSelection = useCallback(
- (chatId: string) => {
- const { selectedChats, selectChatOnly } = useFolderStore.getState()
- if (selectedChats.has(chatId)) {
- setMenuSelection({ chatId, chatIds: chatIds.filter((id) => selectedChats.has(id)) })
- } else {
- selectChatOnly(chatId)
- setMenuSelection({ chatId, chatIds: [chatId] })
- }
- },
- [chatIds]
- )
-
- const onContextMenu = useCallback(
- (event: React.MouseEvent, chatId: string) => {
- captureSelection(chatId)
- hover.setLocked(true)
- menu.preventDismiss()
- menu.handleContextMenu(event)
- },
- [captureSelection, hover.setLocked, menu.preventDismiss, menu.handleContextMenu]
- )
-
- const onMorePointerDown = useCallback(() => {
- if (menu.isOpen) menu.preventDismiss()
- }, [menu.isOpen, menu.preventDismiss])
-
- const onMoreClick = useCallback(
- (event: React.MouseEvent, chatId: string) => {
- if (menu.isOpen && menuSelection?.chatId === chatId) {
- menu.closeMenu()
- return
- }
- const rect = event.currentTarget.getBoundingClientRect()
- captureSelection(chatId)
- hover.setLocked(true)
- menu.openMenuAt({ x: rect.right, y: rect.top })
- },
- [menu.isOpen, menu.closeMenu, menu.openMenuAt, hover.setLocked, menuSelection, captureSelection]
- )
-
- const chatId = selectedChat?.id
- const chatName = selectedChat?.name
- const chatHref = selectedChat?.href
- const chatPinned = selectedChat?.isPinned
-
- const startDelete = useCallback(() => {
- setChatIdsToDelete(menuSelection?.chatIds ?? [])
- }, [menuSelection])
-
- const cancelDelete = useCallback(() => {
- if (!isDeleting) setChatIdsToDelete([])
- }, [isDeleting])
-
- const confirmDelete = useCallback(() => {
- if (chatsToDelete.length === 0 || isDeleting) return
- const redirectIfDeleted = (ids: string[]) => {
- const current = chats.find(
- (chat) => ids.includes(chat.id) && window.location.pathname === chat.href
- )
- if (current) {
- const routes = organizationRoutes(organizationId)
- router.push(current.href.startsWith(`${routes.search}/`) ? routes.search : routes.home)
- }
- }
- const options = {
- onSuccess: () => {
- setChatIdsToDelete([])
- useFolderStore.getState().clearChatSelection()
- redirectIfDeleted(chatIdsToDelete)
- },
- onError: (error: Error) => {
- if (error instanceof MothershipChatDeleteError) {
- const deletedIds = new Set(error.deletedChatIds)
- setChatIdsToDelete((ids) => ids.filter((id) => !deletedIds.has(id)))
- redirectIfDeleted(error.deletedChatIds)
- }
- toast.error(error.message)
- },
- }
- if (chatsToDelete.length === 1) {
- deleteChat.mutate(chatsToDelete[0].id, options)
- } else {
- deleteChats.mutate(
- chatsToDelete.map((chat) => chat.id),
- options
- )
- }
- }, [
- chatIdsToDelete,
- chats,
- chatsToDelete,
- deleteChat.mutate,
- deleteChats.mutate,
- isDeleting,
- organizationId,
- router,
- ])
-
- const startRename = useCallback(() => {
- if (chatId && chatName !== undefined) rename.startRename({ id: chatId, name: chatName })
- }, [chatId, chatName, rename.startRename])
-
- const togglePin = useCallback(() => {
- if (!chatId) return
- pinChat({ chatId, pinned: !chatPinned }, { onError: (error) => toast.error(error.message) })
- }, [chatId, chatPinned, pinChat])
-
- const markRead = useCallback(() => {
- if (chatId) readChat(chatId, { onError: (error) => toast.error(error.message) })
- }, [chatId, readChat])
-
- const markUnread = useCallback(() => {
- if (chatId) unreadChat(chatId, { onError: (error) => toast.error(error.message) })
- }, [chatId, unreadChat])
-
- const openInNewTab = useCallback(() => {
- if (chatHref) window.open(chatHref, '_blank', 'noopener,noreferrer')
- }, [chatHref])
-
- const copyLink = useCallback(async () => {
- if (!chatHref) return
- try {
- await navigator.clipboard.writeText(new URL(chatHref, window.location.origin).href)
- } catch (error) {
- toast.error(getErrorMessage(error, 'Failed to copy link'))
- }
- }, [chatHref])
-
- return {
- menu,
- hover,
- rename,
- selectedChat,
- selectedChats,
- handleChatClick,
- menuOpenChatId: menu.isOpen ? menuSelection?.chatId : null,
- selectedCount: menuSelection?.chatIds.length ?? 0,
- onContextMenu,
- onMorePointerDown,
- onMoreClick,
- startRename,
- chatsToDelete,
- isDeleting,
- startDelete,
- cancelDelete,
- confirmDelete,
- togglePin,
- markRead,
- markUnread,
- openInNewTab,
- copyLink,
- }
-}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts
deleted file mode 100644
index 1230c52530f..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { useOrganizationMothershipChats } from '@/hooks/queries/mothership-chats'
-
-export interface OrganizationChat {
- id: string
- name: string
- href: string
- /** A run is in progress. */
- isActive?: boolean
- /** Has a reply the viewer has not opened. */
- isUnread?: boolean
- isPinned?: boolean
-}
-
-/** Lists only the current member's private organization conversations. */
-export function useOrganizationChats(organizationId: string) {
- const query = useOrganizationMothershipChats(organizationId)
- const chats: OrganizationChat[] = (query.data ?? []).map((chat) => ({
- id: chat.id,
- name: chat.name,
- href:
- chat.mode === 'assistant'
- ? organizationRoutes(organizationId).searchChat(chat.id)
- : organizationRoutes(organizationId).chat(chat.id),
- isActive: chat.isActive,
- isUnread: chat.isUnread,
- isPinned: chat.isPinned,
- }))
- return { chats, isLoading: query.isPending }
-}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts
deleted file mode 100644
index 4e6c15b5b53..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import {
- EMPTY_PINNED_WORKSPACE_IDS,
- useOrderedWorkspacesQuery,
- usePinnedWorkspaceIds,
-} from '@/hooks/queries/workspace'
-
-/**
- * The organization's workspaces the viewer belongs to, for the sidebar's
- * Workspaces section. Read from the viewer's workspace list — the same query and
- * order the workspace switcher uses — narrowed to those the organization owns.
- */
-export function useOrganizationWorkspaces(organizationId: string) {
- const { data = [], isLoading } = useOrderedWorkspacesQuery()
- const { data: pinnedWorkspaceIds = EMPTY_PINNED_WORKSPACE_IDS } = usePinnedWorkspaceIds()
- const workspaces = data.filter((workspace) => workspace.organizationId === organizationId)
-
- return {
- workspaces,
- pinnedWorkspaceIds,
- isLoading,
- }
-}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts
deleted file mode 100644
index 9963f275118..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { OrganizationSidebar } from './organization-sidebar'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx
deleted file mode 100644
index 5d7d19dc225..00000000000
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx
+++ /dev/null
@@ -1,344 +0,0 @@
-'use client'
-
-import { type ComponentProps, memo, useRef, useState } from 'react'
-import { Chip, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
-import { PanelLeft } from '@sim/emcn/icons'
-import { createLogger } from '@sim/logger'
-import { usePathname } from 'next/navigation'
-import { usePostHog } from 'posthog-js/react'
-import { isMacPlatform } from '@/lib/core/utils/platform'
-import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { captureEvent } from '@/lib/posthog/client'
-import {
- ChatsSection,
- OrganizationFooter,
- OrganizationHeader,
- WorkspacesSection,
-} from '@/app/o/[organizationId]/components/organization-sidebar/components'
-import { useOrganizationChats } from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
-import { buildOrganizationNavItems } from '@/app/o/[organizationId]/components/organization-sidebar/navigation'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { OrganizationSettingsSidebar } from '@/app/o/[organizationId]/settings/organization-settings-sidebar'
-import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
-import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
-import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands'
-import {
- HelpModal,
- isNavItemActive,
- NavItemContextMenu,
- SidebarNavChip,
- SidebarTooltip,
-} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
-import {
- SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
- SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
- SIDEBAR_ITEM_GAP_CLASS,
- SIDEBAR_SECTION_GAP_CLASS,
-} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
-import { useSidebarResize } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
-import { isSidebarBackgroundClick } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
-import { useContextMenu } from '@/hooks/use-context-menu'
-import { useFolderStore } from '@/stores/folders/store'
-
-const logger = createLogger('OrganizationSidebar')
-
-/**
- * Opts a control out of the desktop shell's window-drag region. The header row is
- * draggable chrome, so anything clickable inside it has to say so or the click is
- * swallowed by the drag handler.
- */
-const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]'
-
-interface OrganizationChatsProps
- extends Omit, 'chats' | 'isLoading'> {
- organizationId: string
-}
-
-interface OrganizationSidebarProps {
- benchmarkEnabled?: boolean
- simLinksEnabled?: boolean
- peopleEnabled?: boolean
-}
-
-function OrganizationChats({ organizationId, ...props }: OrganizationChatsProps) {
- const { chats, isLoading } = useOrganizationChats(organizationId)
- return (
-
- )
-}
-
-/**
- * The organization surface's rail: the same chrome as the workspace sidebar —
- * header row, pinned nav block, a divided scroll region of sections, and the
- * pinned footer — hosted by the same `WorkspaceChrome`, so collapse, resize, and
- * the desktop hover-peek all behave identically. Collapse and peek state come from
- * the chrome through {@link useSidebarChrome}.
- */
-export const OrganizationSidebar = memo(function OrganizationSidebar({
- benchmarkEnabled = false,
- simLinksEnabled = false,
- peopleEnabled = false,
-}: OrganizationSidebarProps) {
- const { isCollapsed: railCollapsed, isPeeking, onToggle: toggleCollapsed } = useSidebarChrome()
- /** The peek card always renders the expanded layout, whatever the rail's state. */
- const isCollapsed = railCollapsed && !isPeeking
-
- const scrollContainerRef = useRef(null)
- const scrollContentRef = useRef(null)
-
- const pathname = usePathname()
- const posthog = usePostHog()
- const { organization, viewer, searchAccess, mothershipAvailable, canBuild } =
- useOrganizationContext()
- const { handlePointerDown } = useSidebarResize()
- const showCollapsedTooltips = isCollapsed
- const scrollEdges = useScrollEdges(scrollContainerRef, {
- contentRef: scrollContentRef,
- enabled: !isCollapsed,
- })
-
- const isMac = isMacPlatform()
- const canUseHome = mothershipAvailable && canBuild
- const navItems = buildOrganizationNavItems(organization.id, {
- searchAvailable: searchAccess.memberScoped,
- mothershipAvailable: canUseHome,
- benchmarkEnabled,
- simLinksEnabled,
- peopleEnabled,
- })
- const settingsPath = organizationRoutes(organization.id).settings
- const isSettings = pathname === settingsPath || pathname?.startsWith(`${settingsPath}/`)
-
- const [isHelpModalOpen, setIsHelpModalOpen] = useState(false)
- const [menuHref, setMenuHref] = useState(null)
- const {
- isOpen: isHrefMenuOpen,
- position: hrefMenuPosition,
- menuRef: hrefMenuRef,
- handleContextMenu: openHrefMenu,
- closeMenu: closeHrefMenu,
- } = useContextMenu()
-
- const handleHrefContextMenu = (e: React.MouseEvent, href: string) => {
- setMenuHref(href)
- openHrefMenu(e)
- }
-
- const handleHrefMenuClose = () => {
- closeHrefMenu()
- setMenuHref(null)
- }
-
- const handleOpenInNewTab = () => {
- if (menuHref) window.open(menuHref, '_blank', 'noopener,noreferrer')
- }
-
- const handleCopyLink = async () => {
- if (!menuHref) return
- try {
- await navigator.clipboard.writeText(`${window.location.origin}${menuHref}`)
- } catch (error) {
- logger.error('Failed to copy link to clipboard', { error })
- }
- }
-
- const handleOpenDocs = () => {
- window.open(DOCS_URL, '_blank', 'noopener,noreferrer')
- captureEvent(posthog, 'docs_opened', { source: 'help_menu' })
- }
-
- const handleOpenSlackCommunity = () => {
- window.open(SLACK_COMMUNITY_URL, '_blank', 'noopener,noreferrer')
- captureEvent(posthog, 'slack_community_opened', { source: 'help_menu' })
- }
-
- const handleEdgeKeyDown = (e: React.KeyboardEvent) => {
- if (isCollapsed && (e.key === 'Enter' || e.key === ' ')) {
- e.preventDefault()
- toggleCollapsed()
- }
- }
-
- const handleSidebarClick = (event: React.MouseEvent) => {
- if (!isSidebarBackgroundClick(event)) return
- useFolderStore.getState().clearChatSelection()
- }
-
- useRegisterGlobalCommands(() =>
- createCommands([
- {
- id: 'toggle-sidebar',
- handler: () => {
- toggleCollapsed()
- },
- },
- ])
- )
-
- return (
-
-
-
- {/* The peek card already sits below the lane; reserving it again doubles the offset. */}
- {!isPeeking && (
-
- )}
-
-
- {isSettings ? (
-
- ) : (
- <>
- {/* The divider is the pinned block's bottom rule, not the scroll region's top one:
- the region's edge fade masks its own first pixels, which would erase a rule
- drawn there exactly when it should show. Same construction as the footer. */}
-
- {navItems.map((item) => {
- const active = isNavItemActive(item, pathname)
- return (
-
- handleHrefContextMenu(e, item.href as string)}
- />
-
- )
- })}
-
-
-
-
-
- {mothershipAvailable && (canBuild || searchAccess.memberScoped) && (
-
- )}
-
-
- >
- )}
-
setIsHelpModalOpen(true)}
- />
-
-
-
-
-
-
-
- {/* Not on the peek card: the resize hook writes an inline `--sidebar-width` that
- out-specifies the `[data-peek]` rule, stranding the card at a stale width. */}
- {!isPeeking && (
-
- )}
-
- )
-})
diff --git a/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx b/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx
deleted file mode 100644
index bb53c7ad0fc..00000000000
--- a/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import {
- type ClipboardEventHandler,
- type KeyboardEventHandler,
- type ReactNode,
- type RefObject,
- useLayoutEffect,
- useRef,
- useState,
-} from 'react'
-import { cn } from '@sim/emcn'
-import { Search } from '@sim/emcn/icons'
-import { PROMPT_TEXT_CLASSES } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
-import { GrowingTextarea } from '@/app/workspace/[workspaceId]/home/components/user-input/components/growing-textarea'
-import { InputToolbar } from '@/app/workspace/[workspaceId]/home/components/user-input/components/input-toolbar'
-
-interface SearchInputBarProps {
- inputRef: RefObject
- value: string
- disabled?: boolean
- onChange: (value: string) => void
- onSubmit: () => void
- onPaste?: ClipboardEventHandler
- onKeyDown?: KeyboardEventHandler
- placeholder?: string
- 'aria-label'?: string
- leadingControls?: ReactNode
- attachments?: ReactNode
- selectionControl?: ReactNode
- voiceControl?: ReactNode
- submitControl: ReactNode
- floating?: boolean
-}
-
-/** Canonical Search bar shared by raw search and conversational Search. */
-export function SearchInputBar({
- leadingControls,
- attachments,
- inputRef,
- value,
- disabled = false,
- onChange,
- onSubmit,
- onPaste,
- onKeyDown,
- placeholder = 'Search your sources',
- 'aria-label': ariaLabel = 'Search your sources',
- selectionControl,
- voiceControl,
- submitControl,
- floating = false,
-}: SearchInputBarProps) {
- const scrollerRef = useRef(null)
- const leadingRef = useRef(null)
- const trailingRef = useRef(null)
- const textMeasureRef = useRef(null)
- const [expanded, setExpanded] = useState(value.includes('\n'))
- const hasAttachments = Boolean(attachments)
-
- /** Measure against the compact width so expanding the field cannot cause a wrap/collapse loop. */
- useLayoutEffect(() => {
- const bar = scrollerRef.current
- const leading = leadingRef.current
- const trailing = trailingRef.current
- const text = textMeasureRef.current
- if (!bar || !leading || !trailing || !text) return
- const measure = () => {
- const style = getComputedStyle(bar)
- const compactWidth =
- bar.clientWidth -
- Number.parseFloat(style.paddingLeft) -
- Number.parseFloat(style.paddingRight) -
- leading.offsetWidth -
- trailing.offsetWidth -
- Number.parseFloat(getComputedStyle(leading.parentElement!).columnGap) * 2
- setExpanded(
- value.includes('\n') || text.getBoundingClientRect().width > Math.max(0, compactWidth)
- )
- }
- measure()
- const observer = new ResizeObserver(measure)
- for (const element of [bar, leading, trailing]) observer.observe(element)
- return () => observer.disconnect()
- }, [value, hasAttachments])
-
- return (
-
- {attachments}
-
- }
- showModelSelector={false}
- selectionControl={selectionControl}
- voiceControl={voiceControl}
- submitControl={submitControl}
- expanded={hasAttachments || expanded}
- editor={
-
onChange(event.target.value)}
- onKeyDown={(event) => {
- onKeyDown?.(event)
- if (event.defaultPrevented) return
- if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
- event.preventDefault()
- onSubmit()
- }
- }}
- onPaste={onPaste}
- placeholder={placeholder}
- aria-label={ariaLabel}
- autoComplete='off'
- spellCheck={false}
- />
- }
- />
-
-
- {value}
-
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/components/search-landing-history.tsx b/apps/sim/app/o/[organizationId]/components/search-landing-history.tsx
deleted file mode 100644
index 21a31aa0c21..00000000000
--- a/apps/sim/app/o/[organizationId]/components/search-landing-history.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-'use client'
-
-import { type ReactNode, useState } from 'react'
-import { Chip, cn, toast } from '@sim/emcn'
-import { Clock } from '@sim/emcn/icons'
-import { inter } from '@/app/_styles/fonts/inter/inter'
-import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
-import { useClearSearchHistory, useSearchHistory } from '@/hooks/queries/search-history'
-
-interface SearchLandingHistoryProps {
- organizationId: string
- userId: string
- onSearch: (query: string) => void
- children: ReactNode
-}
-
-/** Private shortcuts under the composer; ordinary buttons retain native Tab/Enter navigation. */
-export function SearchLandingHistory({
- organizationId,
- userId,
- onSearch,
- children,
-}: SearchLandingHistoryProps) {
- const history = useSearchHistory(organizationId)
- const clear = useClearSearchHistory(organizationId, userId)
- const [selection, setSelection] = useState<'sources' | 'queries' | null>(null)
- const data = history.isError ? undefined : history.data
- const sources = data?.sources.slice(0, 5) ?? []
- const queries = data?.queries.slice(0, 5) ?? []
- const selected = selection ?? (sources.length > 0 ? 'sources' : 'queries')
- return (
-
- {children}
-
- {(sources.length > 0 || queries.length > 0) && (
-
-
-
- setSelection('sources')}
- >
- Recently viewed
-
- setSelection('queries')}
- >
- Recent searches
-
-
-
- clear.mutate(undefined, { onError: (error) => toast.error(error.message) })
- }
- >
- Clear history
-
-
-
-
- {sources.length > 0 ? (
- sources.map((source) =>
)
- ) : (
-
- Sources you open will appear here.
-
- )}
-
-
- {queries.length > 0 ? (
- queries.map(({ query }) => (
-
- onSearch(query)}>
- {query}
-
-
- ))
- ) : (
-
- Your recent searches will appear here.
-
- )}
-
-
-
- )}
- {history.isError && (
-
- Recent activity couldn’t load.
- void history.refetch()} disabled={history.isFetching}>
- Try again
-
-
- )}
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/components/use-organization-conversation.ts b/apps/sim/app/o/[organizationId]/components/use-organization-conversation.ts
new file mode 100644
index 00000000000..b7d76366983
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/use-organization-conversation.ts
@@ -0,0 +1,484 @@
+'use client'
+
+import { type RefObject, useEffect, useMemo, useRef, useState } from 'react'
+import { toast } from '@sim/emcn'
+import { getErrorMessage } from '@sim/utils/errors'
+import { toRecord } from '@sim/utils/object'
+import { useQueryClient } from '@tanstack/react-query'
+import { createSerializer, useQueryStates } from 'nuqs'
+import { requestJson } from '@/lib/api/client/request'
+import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
+import { getWorkspaceHostContextContract } from '@/lib/api/contracts/workspaces'
+import { useSession } from '@/lib/auth/client'
+import { BrowserStorage } from '@/lib/core/utils/browser-storage'
+import {
+ getMothershipAttachmentPreviewUrl,
+ getMothershipAttachmentUrl,
+} from '@/lib/mothership/chat/attachment-preview'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { organizationDraftKey, SEARCH_ASSISTANT_DRAFT_SLOT } from '@/lib/organizations/draft-key'
+import { useOrganization } from '@/lib/organizations/provider'
+import {
+ type OrganizationSummaryRequest,
+ organizationSearchParsers,
+ organizationSearchUrlKeys,
+} from '@/lib/organizations/search-params'
+import { isWorkspaceResourceKind, workspaceResourcePath } from '@/lib/resources'
+import { assertMultiFileUploadAdmission } from '@/lib/uploads/client/admission'
+import {
+ ASSISTANT_IMAGE_MAX_BYTES,
+ ASSISTANT_IMAGE_MAX_COUNT,
+ ASSISTANT_IMAGE_MAX_TOTAL_BYTES,
+ isAssistantImageType,
+} from '@/lib/uploads/shared/assistant-images'
+import type { ConversationComposerProps } from '@/app/o/[organizationId]/home/components/composer'
+import { useSearchHistoryActions } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-history-context'
+import {
+ getMothershipUseChatOptions,
+ useChat,
+} from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
+import {
+ useChatResourcePanel,
+ useResourcePanelController,
+} from '@/app/workspace/[workspaceId]/home/hooks/use-resource-panel'
+import { useRestoredChatEntry } from '@/app/workspace/[workspaceId]/home/hooks/use-restored-chat-entry'
+import { fetchWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
+import {
+ resourceParam,
+ searchFilterParsers,
+} from '@/app/workspace/[workspaceId]/home/search-params'
+import type {
+ ChatRequestMode,
+ FileAttachmentForApi,
+ WorkspaceResourceRef,
+} from '@/app/workspace/[workspaceId]/home/types'
+import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
+import {
+ type AttachedFile,
+ useFileAttachments,
+} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
+import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
+import { type PrepareChatSubmission, useComposerSubmission } from '@/hooks/use-composer-submission'
+import { useHydrated } from '@/hooks/use-hydrated'
+import { readDraft, useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
+import type { ChatContext } from '@/stores/panel'
+
+/** Search's Assistant always runs its retrieval at this level. */
+const ASSISTANT_SEARCH_LEVEL = 'fast' as const
+
+/** A turn's mode, with Assistant's fixed retrieval level and any search scope it carries. */
+export function chatSendOptions(
+ requestMode: ChatRequestMode,
+ assistantSearch?: WorkspaceSearchFilters
+) {
+ return {
+ requestMode,
+ ...(requestMode === 'assistant' ? { assistantSearchLevel: ASSISTANT_SEARCH_LEVEL } : {}),
+ ...(assistantSearch ? { assistantSearch } : {}),
+ }
+}
+
+const serializeSearchConversation = createSerializer({
+ ...organizationSearchParsers,
+ ...searchFilterParsers,
+ [resourceParam.key]: resourceParam.parser,
+})
+
+/** A finished upload as the chat API and the saved draft carry it. */
+function toApiAttachments(files: readonly AttachedFile[]): FileAttachmentForApi[] {
+ return files.flatMap((file) =>
+ file.key
+ ? [
+ {
+ id: file.id,
+ key: file.key,
+ filename: file.name,
+ media_type: file.type,
+ size: file.size,
+ path: file.path,
+ },
+ ]
+ : []
+ )
+}
+
+/** Where the conversation runs: what differs beyond how it is drawn. */
+type ConversationSurface =
+ | {
+ kind: 'page'
+ /** Names this surface's new-chat draft, for a surface that must not share another's. */
+ draftSlot?: string
+ }
+ | {
+ kind: 'panel'
+ draftNamespace: string
+ fileDropTarget: RefObject
+ onChatIdResolved: (chatId: string) => void
+ prepareSubmission: PrepareChatSubmission
+ onSubmitted: () => void
+ }
+
+interface UseConversationGateOptions {
+ chatId?: string
+ requestMode: ChatRequestMode
+}
+
+/** Whether the surface may mount, and for which identity. Its key remounts on any change to one. */
+export function useConversationGate({ chatId, requestMode }: UseConversationGateOptions) {
+ const { organization, searchAccess, canBuild, mothershipAvailable } = useOrganization()
+ const { data: session } = useSession()
+ const isClient = useHydrated()
+ const isRestoredChatEntry = useRestoredChatEntry({ chatId })
+ const userId = session?.user?.id
+ if (
+ !mothershipAvailable ||
+ (requestMode === 'assistant' ? !searchAccess.memberScoped : !canBuild)
+ )
+ return { status: 'denied' } as const
+ if (isRestoredChatEntry) return { status: 'restoring' } as const
+ if (!isClient || !userId) return { status: 'waiting' } as const
+ return {
+ status: 'ready',
+ instanceKey: `${userId}:${organization.id}:${chatId ?? 'new'}`,
+ } as const
+}
+
+interface UseOrganizationConversationOptions {
+ surface: ConversationSurface
+ chatId?: string
+ requestMode: ChatRequestMode
+ active?: boolean
+ summaryRequest?: OrganizationSummaryRequest
+ onSummaryConsumed?: (id: string) => void
+}
+
+/**
+ * An organization conversation's behavior: the chat, its drafts, attachments, submission, and
+ * resources. Its two views draw it, the extension's panel and the page's. This module stays free of
+ * the design system so the extension can bundle it.
+ */
+export function useOrganizationConversation({
+ surface,
+ chatId,
+ requestMode: destinationMode,
+ active = true,
+ summaryRequest,
+ onSummaryConsumed,
+}: UseOrganizationConversationOptions) {
+ const { organization, canBuild } = useOrganization()
+ const panelSurface = surface.kind === 'panel' ? surface : null
+ const { data: session } = useSession()
+ const userId = session?.user?.id
+ const planEnabled = useFeatureFlag('mothership-plan-mode')
+ const [planning, setPlanning] = useState(false)
+ const requestMode =
+ destinationMode === 'agent' && !chatId && planEnabled && planning ? 'plan' : destinationMode
+ const [searchParams] = useQueryStates(
+ { ...organizationSearchParsers, ...searchFilterParsers },
+ organizationSearchUrlKeys
+ )
+ const controller = useResourcePanelController()
+ const queryClient = useQueryClient()
+ const chat = useChat({ organizationId: organization.id }, chatId, {
+ ...getMothershipUseChatOptions({
+ requestMode,
+ getChatUrl:
+ destinationMode === 'assistant'
+ ? (id) =>
+ serializeSearchConversation(organizationRoutes(organization.id).searchChat(id), {
+ ...searchParams,
+ mode: active ? 'assistant' : 'search',
+ resource: controller.activeResourceState[0],
+ })
+ : undefined,
+ onResourceEvent: controller.onResourceEvent,
+ activeResourceState: controller.activeResourceState,
+ }),
+ ...(panelSurface
+ ? { projectsDesktopTabs: false, onChatIdResolved: panelSurface.onChatIdResolved }
+ : {}),
+ })
+ const newDraftSlot =
+ surface.kind === 'panel'
+ ? 'home'
+ : (surface.draftSlot ??
+ (destinationMode === 'assistant' ? SEARCH_ASSISTANT_DRAFT_SLOT : 'home'))
+ const draftKeyFor = (slot: string) =>
+ organizationDraftKey(userId, organization.id, slot, {
+ namespace: panelSurface?.draftNamespace,
+ })
+ const initialDraftKey = draftKeyFor(chatId ?? newDraftSlot)
+ const draftKey = draftKeyFor(chat.resolvedChatId ?? chatId ?? newDraftSlot)
+ const [legacyDraftKey] = useState(() => {
+ if (panelSurface || chatId) return null
+ const key = organizationDraftKey(userId, organization.id, 'new')
+ if (!useMothershipDraftsStore.getState().drafts[key]) return null
+ const persisted = toRecord(BrowserStorage.getItem('organization-chat-mode', null))
+ const modes = toRecord(toRecord(persisted.state).modes)
+ const legacyMode =
+ !canBuild || modes[`${userId}:${organization.id}`] === 'assistant' ? 'assistant' : 'agent'
+ return legacyMode === destinationMode ? key : null
+ })
+ const draftKeys = useMemo(
+ () => [draftKey, initialDraftKey, ...(legacyDraftKey ? [legacyDraftKey] : [])],
+ [draftKey, initialDraftKey, legacyDraftKey]
+ )
+ const [initialDraft] = useState(() =>
+ readDraft(useMothershipDraftsStore.getState().drafts, draftKeys)
+ )
+ const [restoredContexts, setRestoredContexts] = useState(
+ () => initialDraft?.contexts ?? []
+ )
+ useEffect(() => {
+ const store = useMothershipDraftsStore.getState()
+ if (legacyDraftKey) store.migrateDraft(legacyDraftKey, initialDraftKey)
+ store.migrateDraft(initialDraftKey, draftKey)
+ }, [initialDraftKey, draftKey, legacyDraftKey])
+ const hasChat = Boolean(chatId || chat.messages.length)
+ const panel = useChatResourcePanel(chat, controller, userId)
+ const addResource = panel.addResourceFromUser
+ const selectResource = async (ref: WorkspaceResourceRef) => {
+ if (!ref.workspaceId) {
+ toast.error('This resource has no workspace address.')
+ return
+ }
+ const destination = panelSurface ? window.open('about:blank', '_blank') : null
+ if (panelSurface && !destination) {
+ toast.error('Allow pop-ups to open this resource in Sim.')
+ return
+ }
+ if (destination) destination.opener = null
+ try {
+ const host = await requestJson(getWorkspaceHostContextContract, {
+ params: { id: ref.workspaceId },
+ })
+ if (host.hostOrganizationId !== organization.id) {
+ destination?.close()
+ toast.error('This resource is outside this organization.')
+ return
+ }
+ const resource = await fetchWorkspaceResourceRef(queryClient, ref.workspaceId, ref)
+ if (!resource) {
+ destination?.close()
+ toast.error(`Couldn't find "${ref.title}" in its workspace`)
+ return
+ }
+ if (destination) {
+ const kind =
+ resource.type === 'knowledgebase'
+ ? 'knowledge'
+ : resource.type === 'filefolder'
+ ? 'folder'
+ : resource.type
+ destination.location.replace(
+ isWorkspaceResourceKind(kind)
+ ? workspaceResourcePath(ref.workspaceId, kind, resource.id)
+ : `/workspace/${encodeURIComponent(ref.workspaceId)}/home?resource=${encodeURIComponent(resource.id)}`
+ )
+ } else addResource({ ...resource, workspaceId: ref.workspaceId })
+ } catch {
+ destination?.close()
+ toast.error(`Couldn't open "${ref.title}". Check your access and try again.`)
+ }
+ }
+ const submission = useComposerSubmission({ mode: requestMode })
+ const files = useFileAttachments({
+ fileDropTarget: panelSurface?.fileDropTarget,
+ disabled: submission.pending,
+ userId,
+ organizationId: organization.id,
+ requestMode,
+ initialAttachments: initialDraft?.fileAttachments,
+ })
+ useEffect(() => {
+ const store = useMothershipDraftsStore.getState()
+ const fileAttachments = toApiAttachments(files.attachedFiles.filter((file) => !file.uploading))
+ store.setDraft(draftKey, {
+ ...(store.drafts[draftKey] ?? { text: '' }),
+ fileAttachments,
+ })
+ }, [draftKey, files.attachedFiles])
+ useEffect(() => {
+ if (chat.error) toast.error(chat.error)
+ }, [chat.error])
+ const { recordQuery } = useSearchHistoryActions()
+ const { sendMessage } = chat
+ const { mutate: markRead } = useMarkMothershipChatRead({ organizationId: organization.id })
+
+ useEffect(() => {
+ if (active && chat.resolvedChatId && !chat.isSending && !chat.isReconnecting)
+ markRead(chat.resolvedChatId)
+ }, [active, chat.resolvedChatId, chat.isSending, chat.isReconnecting, markRead])
+
+ const consumedSummary = useRef(null)
+ useEffect(() => {
+ if (!active || !summaryRequest || consumedSummary.current === summaryRequest.id) return
+ consumedSummary.current = summaryRequest.id
+ onSummaryConsumed?.(summaryRequest.id)
+ void sendMessage(
+ summaryRequest.message,
+ undefined,
+ undefined,
+ chatSendOptions('assistant', summaryRequest.assistantSearch)
+ )
+ }, [active, summaryRequest, onSummaryConsumed, sendMessage])
+
+ const send = (
+ message: string,
+ fileAttachments?: FileAttachmentForApi[],
+ contexts?: ChatContext[],
+ assistantSearch?: WorkspaceSearchFilters
+ ) => {
+ if (requestMode !== 'assistant' && !canBuild) return
+ if (requestMode === 'assistant') recordQuery(message)
+ if (requestMode !== 'assistant' && !panelSurface) panel.prepareResourceViewForAgentTurn()
+ void sendMessage(
+ message,
+ fileAttachments,
+ contexts,
+ chatSendOptions(requestMode, assistantSearch)
+ )
+ }
+ const submit = (text: string, contexts?: ChatContext[]) => {
+ if (files.attachedFiles.some((file) => file.uploading)) return false
+ if (requestMode === 'assistant') {
+ if (files.attachedFiles.some((file) => !isAssistantImageType(file.type))) {
+ toast.error('Remove unsupported files to use Assistant.')
+ return false
+ }
+ try {
+ assertMultiFileUploadAdmission(files.attachedFiles, {
+ maxFiles: ASSISTANT_IMAGE_MAX_COUNT,
+ maxFileBytes: ASSISTANT_IMAGE_MAX_BYTES,
+ maxTotalBytes: ASSISTANT_IMAGE_MAX_TOTAL_BYTES,
+ })
+ } catch (error) {
+ toast.error(getErrorMessage(error, 'Check the attached images before sending.'))
+ return false
+ }
+ }
+ const attachments = toApiAttachments(files.attachedFiles)
+ const commit = ({
+ message,
+ contexts: preparedContexts,
+ }: {
+ message: string
+ contexts?: ChatContext[]
+ }) => {
+ if (!message && !attachments.length) return false
+ useMothershipDraftsStore.getState().clearDraft(draftKey)
+ send(message, attachments.length ? attachments : undefined, preparedContexts)
+ panelSurface?.onSubmitted()
+ setRestoredContexts([])
+ files.clearAttachedFiles()
+ }
+ return panelSurface
+ ? panelSurface.prepareSubmission(text.trim(), contexts, commit)
+ : commit({ message: text.trim(), contexts })
+ }
+
+ const editQueuedMessage = (id: string) => {
+ const queued = chat.editQueuedMessage(id)
+ if (queued) {
+ const store = useMothershipDraftsStore.getState()
+ store.setDraft(draftKey, {
+ ...store.drafts[draftKey],
+ text: queued.content,
+ contexts: queued.contexts,
+ })
+ setRestoredContexts(queued.contexts ?? [])
+ files.restoreAttachedFiles(
+ (queued.fileAttachments ?? []).map((file) => ({
+ id: file.id,
+ key: file.key,
+ name: file.filename,
+ type: file.media_type,
+ size: file.size,
+ path:
+ file.path ||
+ getMothershipAttachmentPreviewUrl(file) ||
+ getMothershipAttachmentUrl(file),
+ previewUrl: getMothershipAttachmentPreviewUrl(file),
+ uploading: false,
+ }))
+ )
+ }
+ return queued
+ }
+
+ const allowBuildControls = requestMode !== 'assistant' && canBuild
+ const stopGeneration = () => {
+ void chat.stopGeneration()
+ }
+ const composerProps: ConversationComposerProps = {
+ requestMode,
+ submission,
+ onPlanChange:
+ !hasChat && destinationMode === 'agent' && planEnabled
+ ? () => setPlanning((value) => !value)
+ : undefined,
+ draftKeys,
+ restoredContexts,
+ messages: chat.messages,
+ onEditQueuedTail: () => {
+ const tail = chat.messageQueue.at(-1)
+ return tail ? Boolean(editQueuedMessage(tail.id)) : false
+ },
+ files,
+ isInitialView: !hasChat,
+ isSending: chat.isSending || chat.isReconnecting,
+ onSubmit: submit,
+ onSendQueuedHead: () => {
+ void chat.sendNow()
+ },
+ onStop: stopGeneration,
+ }
+ /** What both views' threads take; each adds its own layout around them. */
+ const threadProps = {
+ onViewSources: (messageId: string, requestId?: string) =>
+ addResource({
+ type: 'sources',
+ id: 'cited-sources',
+ title: 'Sources',
+ sources: { messageId, ...(requestId ? { requestId } : {}) },
+ }),
+ messages: chat.messages,
+ isSending: chat.isSending,
+ isReconnecting: chat.isReconnecting,
+ isLoading: Boolean(chatId) && !chat.messages.length && chat.isChatHistoryPending,
+ onSubmit: send,
+ onStopGeneration: stopGeneration,
+ messageQueue: chat.messageQueue,
+ editingQueuedId: chat.editingQueuedId,
+ dispatchingHeadId: chat.dispatchingHeadId,
+ onRemoveQueuedMessage: chat.removeFromQueue,
+ onSendQueuedMessage: chat.sendNow,
+ onEditQueuedMessage: editQueuedMessage,
+ onCancelQueueEdit: chat.cancelQueueEdit,
+ userId,
+ chatId: chat.resolvedChatId,
+ onWorkspaceResourceSelect: requestMode !== 'assistant' ? selectResource : undefined,
+ reserveResourceHeader: chat.resources.length > 0 || allowBuildControls,
+ }
+ const reviewProps = {
+ organizationId: organization.id,
+ chatId: chat.resolvedChatId,
+ messages: chat.messages,
+ active: chat.isSending,
+ enabled: requestMode === 'assistant',
+ }
+ return {
+ organization,
+ chat,
+ controller,
+ panel,
+ hasChat,
+ requestMode,
+ allowBuildControls,
+ searchParams,
+ send,
+ composerProps,
+ threadProps,
+ reviewProps,
+ }
+}
diff --git a/apps/sim/app/o/[organizationId]/error.tsx b/apps/sim/app/o/[organizationId]/error.tsx
index 42dca68dcb2..e74b3437b6d 100644
--- a/apps/sim/app/o/[organizationId]/error.tsx
+++ b/apps/sim/app/o/[organizationId]/error.tsx
@@ -1,18 +1,27 @@
'use client'
-import {
- type ErrorBoundaryProps,
- ErrorState,
-} from '@/app/workspace/[workspaceId]/components/error/error'
+import { useEffect } from 'react'
+import { createLogger } from '@sim/logger'
+import type { ErrorBoundaryProps } from '@/app/workspace/[workspaceId]/components/error/error'
+import { RouteStatus } from '@/platform/app/(shell)/_components/route-status/route-status'
+import { Button } from '@/platform/design-system/components/button/button'
+
+const logger = createLogger('OrganizationError')
export default function OrganizationError({ error, reset }: ErrorBoundaryProps) {
+ useEffect(() => {
+ logger.error('OrganizationError error:', { error: error.message, digest: error.digest })
+ }, [error.message, error.digest])
+
return (
-
+ Try again
+
+ }
/>
)
}
diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer-parts.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer-parts.tsx
new file mode 100644
index 00000000000..3e1696386fb
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer-parts.tsx
@@ -0,0 +1,52 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { ASSISTANT_IMAGE_ACCEPT_ATTRIBUTE } from '@/lib/uploads/shared/assistant-images'
+import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
+import type { ConversationComposerProps } from '@/app/o/[organizationId]/home/components/composer/use-composer'
+import { AttachedFilesList } from '@/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list'
+import { DropOverlay } from '@/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/drop-overlay'
+
+interface ComposerAttachmentsProps {
+ files: ConversationComposerProps['files']
+ additional?: ReactNode
+}
+
+/** The attached files, with whatever else the view attaches beside them. */
+export function ComposerAttachments({ files, additional }: ComposerAttachmentsProps) {
+ if (!files.attachedFiles.length && !additional) return null
+ return (
+
+ )
+}
+
+interface ComposerFileTargetsProps {
+ files: ConversationComposerProps['files']
+ imagesOnly: boolean
+}
+
+/** The hidden picker and the drop overlay every composer view holds. */
+export function ComposerFileTargets({ files, imagesOnly }: ComposerFileTargetsProps) {
+ return (
+ <>
+
+ {files.isDragging && }
+ >
+ )
+}
+
+/** Keeps the field below a quarter of a phone's viewport, and a screenful elsewhere. */
+export const COMPOSER_FIELD_HEIGHT_CLASSES =
+ 'max-h-[200px] max-md:max-h-[min(200px,calc(var(--mobile-viewport-height,100dvh)*0.25))]'
diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx
index 39986d80d5d..5f1e45f6c8c 100644
--- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx
+++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx
@@ -1,6 +1,8 @@
/** @vitest-environment jsdom */
+
import { act, type ComponentProps, useState } from 'react'
-import { ToastProvider } from '@sim/emcn'
+import { ToastProvider, toast } from '@sim/emcn'
+import { stubResizeObserver } from '@sim/testing/mocks/browser-apis.mock'
import {
createMockDeploymentShape,
deploymentShapeMock,
@@ -16,12 +18,15 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useComposerSubmission } from '@/hooks/use-composer-submission'
import type { useSpeechToText } from '@/hooks/use-speech-to-text'
+import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
import {
useMothershipEffortStore,
useMothershipPlanEffortStore,
} from '@/stores/mothership-effort/store'
+import type { ChatContext } from '@/stores/panel'
const mocks = vi.hoisted(() => ({
+ workspacesLoading: false,
plan: false,
advanced: false,
speech: vi.fn(),
@@ -51,7 +56,12 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/lib/core/config/deployment-shape', () => deploymentShapeMock)
vi.mock('@/hooks/queries/workspace', () => ({
- useWorkspacesQuery: () => ({ data: mocks.workspaces }),
+ useWorkspacesQuery: () =>
+ mocks.workspacesLoading ? { data: undefined, isPending: true } : { data: mocks.workspaces },
+ fetchWorkspacesList: async () => {
+ mocks.workspacesLoading = false
+ return mocks.workspaces
+ },
}))
vi.mock('@/hooks/queries/skills', () => ({
useSkills: () => ({ data: [] }),
@@ -86,16 +96,23 @@ vi.mock('@/hooks/use-speech-to-text', () => ({ useSpeechToText: mocks.speech }))
vi.mock('@/lib/uploads/client/session-upload', () => ({ uploadInternalFileSession: mocks.upload }))
vi.mock('@/hooks/use-animated-placeholder', () => ({ useAnimatedPlaceholder: () => 'Ask Sim to' }))
vi.mock('@/hooks/use-chat-input-focus', () => ({ useChatInputFocus: vi.fn() }))
-vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => organizationProviderMock)
+vi.mock('@/lib/organizations/provider', () => organizationProviderMock)
-import { Composer } from '@/app/o/[organizationId]/home/components/composer/composer'
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
+import { PageComposer as Composer } from '@/platform/app/(shell)/_components/conversation/page-composer'
+
+const DRAFT_KEY = 'composer-test-draft'
+const DRAFT_KEYS = [DRAFT_KEY]
+/** One identity, since a new list is how a host restores a prompt. */
+const NO_RESTORED_CONTEXTS: ChatContext[] = []
+const writeDraft = (text: string) =>
+ useMothershipDraftsStore.getState().setDraft(DRAFT_KEY, { text })
const deploymentShape = () => createMockDeploymentShape()
deploymentShapeMockFns.mockUseDeploymentShape.mockImplementation(deploymentShape)
deploymentShapeMockFns.mockGetDeploymentShape.mockImplementation(deploymentShape)
-organizationProviderMockFns.mockUseOrganizationContext.mockReturnValue({
+organizationProviderMockFns.mockUseOrganization.mockReturnValue({
organization: { id: 'organization-a' },
})
@@ -104,9 +121,12 @@ let container: HTMLDivElement
let queryClient: QueryClient
beforeEach(() => {
+ vi.spyOn(toast, 'error').mockReturnValue('')
mocks.advanced = false
+ mocks.workspacesLoading = false
useMothershipEffortStore.getState().reset()
useMothershipPlanEffortStore.getState().reset()
+ useMothershipDraftsStore.setState({ drafts: {} })
mocks.plan = false
vi.clearAllMocks()
mocks.workspaces = [
@@ -146,14 +166,7 @@ beforeEach(() => {
items = { add: (file: File) => this.files.push(file) }
}
)
- vi.stubGlobal(
- 'ResizeObserver',
- class {
- observe() {}
- disconnect() {}
- unobserve() {}
- }
- )
+ stubResizeObserver()
vi.stubGlobal(
'URL',
class extends URL {
@@ -196,13 +209,13 @@ async function render(
isInitialView: boolean,
initialValue = 'Summarize',
requestMode: 'agent' | 'assistant' | 'plan' = 'assistant',
- controls: Pick<
- ComponentProps,
- 'isSending' | 'restoredContexts' | 'onSendQueuedHead'
- > = { isSending: false }
+ controls: Pick, 'isSending'> &
+ Partial, 'restoredContexts' | 'onSendQueuedHead'>> = {
+ isSending: false,
+ }
) {
+ writeDraft(initialValue)
function Harness() {
- const [value, setValue] = useState(initialValue)
const submission = useComposerSubmission({ mode: requestMode })
const files = useFileAttachments({
userId: 'user-a',
@@ -214,18 +227,19 @@ async function render(
false}
+ messages={[]}
onSubmit={(text, contexts) => {
mocks.submit(text, files.attachedFiles)
mocks.contexts(contexts)
- setValue('')
+ useMothershipDraftsStore.getState().clearDraft(DRAFT_KEY)
files.clearAttachedFiles()
}}
/>
@@ -276,12 +290,12 @@ it.each([false, true])(
'keeps restored queued skills scoped and warns about lost context (%s)',
async (invalidContext) => {
let restore: () => void = () => {}
+ writeDraft('Original draft')
function Harness() {
- const [value, setValue] = useState('Original draft')
const [contexts, setContexts] =
- useState['restoredContexts']>()
+ useState['restoredContexts']>(NO_RESTORED_CONTEXTS)
restore = () => {
- setValue('/review fix this @Browser')
+ writeDraft('/review fix this @Browser')
setContexts([
{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
...(invalidContext
@@ -307,13 +321,15 @@ it.each([false, true])(
{}}
+ onSendQueuedHead={() => {}}
+ onEditQueuedTail={() => false}
+ messages={[]}
onSubmit={mocks.submit}
/>
)
@@ -417,3 +433,297 @@ it('preserves mode-specific models and unsent effort across composer remounts',
expect(container.textContent).toContain('GPT-6.1 Sol')
expect(container.textContent).toContain('Extra High')
})
+
+/** Flushes until the lists have landed and the editor has seen them, however busy the runner is. */
+async function settleQueries(until?: () => void) {
+ await act(async () => {
+ await vi.waitFor(() => {
+ expect(queryClient.isFetching()).toBe(0)
+ until?.()
+ })
+ })
+}
+
+function required(selector: string) {
+ const element = container.querySelector(selector)
+ if (!element) throw new Error(`Nothing matches ${selector}`)
+ return element
+}
+
+function typeInto(input: HTMLTextAreaElement, text: string) {
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
+ setter?.call(input, text)
+ input.setSelectionRange(text.length, text.length)
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+}
+
+it('warms the lists when the viewer presses the field, and opening the + menu does too', async () => {
+ await render(true, '', 'agent')
+ await act(async () =>
+ required('[aria-label="Ask Sim"]').dispatchEvent(
+ new MouseEvent('pointerdown', { bubbles: true })
+ )
+ )
+ expect(mocks.skillQuery).toHaveBeenCalledExactlyOnceWith('workspace-a')
+ expect(mocks.resourceMenu.mock.lastCall?.[0].warm).toBe(true)
+
+ await act(async () => root.unmount())
+ root = createRoot(container)
+ queryClient.clear()
+ await render(true, '', 'agent')
+ expect(mocks.resourceMenu.mock.lastCall?.[0].warm).toBe(false)
+ await act(async () => required('button[aria-label="Add resources"]').click())
+ expect(mocks.openResourceMenu).toHaveBeenCalledOnce()
+ expect(mocks.resourceMenu.mock.lastCall?.[0].warm).toBe(true)
+})
+
+it('a slash reads the skills only, and the resource lists stay cold', async () => {
+ await render(true, '', 'agent')
+ await act(async () => typeInto(required('[aria-label="Ask Sim"]'), '/'))
+ expect(mocks.skillQuery).toHaveBeenCalledExactlyOnceWith('workspace-a')
+ expect(mocks.resourceMenu.mock.lastCall?.[0].warm).toBe(false)
+})
+
+it('a draft carrying a skill still chips once its list arrives', async () => {
+ await render(true, '/review this', 'agent')
+ const input = required('[aria-label="Ask Sim"]')
+ expect(mocks.skillQuery).toHaveBeenCalledExactlyOnceWith('workspace-a')
+ await settleQueries(() => expect(input.value).toBe(' review this'))
+ await act(async () => required('button[aria-label="Send"]').click())
+ expect(mocks.contexts).toHaveBeenLastCalledWith([
+ { kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
+ ])
+})
+
+it('a skill typed to its boundary before the lists land chips when they do, caret in place', async () => {
+ let resolveSkills: (skills: unknown[]) => void = () => {}
+ mocks.skillQuery.mockReturnValue(
+ new Promise((resolve) => {
+ resolveSkills = resolve
+ })
+ )
+ await render(true, '', 'agent')
+ const input = required('[aria-label="Ask Sim"]')
+ await act(async () => typeInto(input, '/'))
+ await act(async () => typeInto(input, '/review now'))
+ expect(input.value).toBe('/review now')
+ await act(async () => {
+ input.setSelectionRange(9, 9)
+ resolveSkills([
+ {
+ id: 'skill-a',
+ workspaceId: 'workspace-a',
+ userId: 'user-a',
+ name: 'review',
+ description: 'Review a draft',
+ content: 'Review carefully.',
+ canEdit: true,
+ createdAt: '',
+ updatedAt: '',
+ },
+ ])
+ })
+ await settleQueries(() => expect(input.value).toBe(' review now'))
+ expect(input.selectionStart).toBe(9)
+})
+
+it('holds a slash message sent before the skills land, then sends it with its skill', async () => {
+ let resolveSkills: (skills: unknown[]) => void = () => {}
+ mocks.skillQuery.mockReturnValue(
+ new Promise((resolve) => {
+ resolveSkills = resolve
+ })
+ )
+ await render(true, '', 'agent')
+ const input = required('[aria-label="Ask Sim"]')
+ await act(async () => typeInto(input, '/'))
+ await act(async () => typeInto(input, '/review this'))
+ const pressEnter = () =>
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+ await act(async () => pressEnter())
+ expect(mocks.contexts).not.toHaveBeenCalled()
+ expect(input.closest('[aria-busy="true"]')).not.toBeNull()
+ await act(async () => {
+ resolveSkills([
+ {
+ id: 'skill-a',
+ workspaceId: 'workspace-a',
+ userId: 'user-a',
+ name: 'review',
+ description: 'Review a draft',
+ content: 'Review carefully.',
+ canEdit: true,
+ createdAt: '',
+ updatedAt: '',
+ },
+ ])
+ })
+ await settleQueries()
+ await vi.waitFor(async () => {
+ await act(async () => {})
+ expect(mocks.contexts).toHaveBeenLastCalledWith([
+ { kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
+ ])
+ })
+ expect(input.closest('[aria-busy="true"]')).toBeNull()
+})
+
+it('a message with no slash goes out without waiting for any list', async () => {
+ mocks.skillQuery.mockReturnValue(new Promise(() => {}))
+ await render(true, '', 'agent')
+ const input = required('[aria-label="Ask Sim"]')
+ await act(async () => typeInto(input, 'just a question'))
+ await act(async () => required('button[aria-label="Send"]').click())
+ expect(mocks.contexts).toHaveBeenCalledTimes(1)
+})
+
+it('editing a held message cancels the hold', async () => {
+ mocks.skillQuery.mockReturnValue(new Promise(() => {}))
+ await render(true, '', 'agent')
+ const input = required('[aria-label="Ask Sim"]')
+ await act(async () => typeInto(input, '/review this'))
+ await act(async () => required('button[aria-label="Send"]').click())
+ expect(input.closest('[aria-busy="true"]')).not.toBeNull()
+ await act(async () => typeInto(input, '/review this now'))
+ expect(input.closest('[aria-busy="true"]')).toBeNull()
+ expect(mocks.contexts).not.toHaveBeenCalled()
+})
+
+function holdThenExpire() {
+ const expire = new AbortController()
+ vi.spyOn(AbortSignal, 'timeout').mockReturnValue(expire.signal)
+ return expire
+}
+
+it('tells the viewer when the lists never land, keeps the message, and sends plain on the next try', async () => {
+ const expire = holdThenExpire()
+ mocks.skillQuery.mockReturnValue(new Promise(() => {}))
+ await render(true, '', 'agent')
+ const input = required('[aria-label="Ask Sim"]')
+ await act(async () => typeInto(input, '/review this'))
+ await act(async () => required('button[aria-label="Send"]').click())
+ expect(input.closest('[aria-busy="true"]')).not.toBeNull()
+ await act(async () => expire.abort())
+ expect(mocks.submit).not.toHaveBeenCalled()
+ expect(input.value).toBe('/review this')
+ expect(input.closest('[aria-busy="true"]')).toBeNull()
+ expect(toast.error).toHaveBeenCalledOnce()
+ await act(async () => required('button[aria-label="Send"]').click())
+ expect(mocks.submit).toHaveBeenCalledOnce()
+ expect(mocks.submit.mock.lastCall?.[0]).toBe('/review this')
+})
+
+it('a declined message edited and typed again waits and warns again', async () => {
+ const firstExpiry = holdThenExpire()
+ mocks.skillQuery.mockReturnValue(new Promise(() => {}))
+ await render(true, '', 'agent')
+ const input = required('[aria-label="Ask Sim"]')
+ const send = () => required('button[aria-label="Send"]')
+ await act(async () => typeInto(input, '/review this'))
+ await act(async () => send().click())
+ await act(async () => firstExpiry.abort())
+ expect(toast.error).toHaveBeenCalledOnce()
+ await act(async () => typeInto(input, '/review this now'))
+ await act(async () => typeInto(input, '/review this'))
+ const secondExpiry = holdThenExpire()
+ await act(async () => send().click())
+ expect(mocks.submit).not.toHaveBeenCalled()
+ expect(input.closest('[aria-busy="true"]')).not.toBeNull()
+ await act(async () => secondExpiry.abort())
+ expect(toast.error).toHaveBeenCalledTimes(2)
+ expect(mocks.submit).not.toHaveBeenCalled()
+})
+
+it('a list that fails while the send waits tells the viewer the same way', async () => {
+ let failSkills: (error: Error) => void = () => {}
+ mocks.skillQuery.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ failSkills = reject
+ })
+ )
+ await render(true, '', 'agent')
+ const input = required('[aria-label="Ask Sim"]')
+ await act(async () => typeInto(input, '/review this'))
+ await act(async () => required('button[aria-label="Send"]').click())
+ await act(async () => failSkills(new Error('down')))
+ await settleQueries()
+ await vi.waitFor(async () => {
+ await act(async () => {})
+ expect(toast.error).toHaveBeenCalledOnce()
+ })
+ expect(mocks.submit).not.toHaveBeenCalled()
+})
+
+it('a path in a held message goes out as written once the lists land', async () => {
+ let resolveSkills: (skills: unknown[]) => void = () => {}
+ mocks.skillQuery.mockReturnValue(
+ new Promise((resolve) => {
+ resolveSkills = resolve
+ })
+ )
+ await render(true, '', 'agent')
+ const input = required('[aria-label="Ask Sim"]')
+ await act(async () => typeInto(input, 'read /etc/hosts'))
+ await act(async () => required('button[aria-label="Send"]').click())
+ await act(async () => resolveSkills([]))
+ await settleQueries()
+ await vi.waitFor(async () => {
+ await act(async () => {})
+ expect(mocks.submit).toHaveBeenCalledOnce()
+ })
+ expect(mocks.submit.mock.lastCall?.[0]).toBe('read /etc/hosts')
+ expect(toast.error).not.toHaveBeenCalled()
+})
+
+it('editing away and back does not revive a cancelled hold', async () => {
+ let resolveSkills: (skills: unknown[]) => void = () => {}
+ mocks.skillQuery.mockReturnValue(
+ new Promise((resolve) => {
+ resolveSkills = resolve
+ })
+ )
+ await render(true, '', 'agent')
+ const input = required('[aria-label="Ask Sim"]')
+ await act(async () => typeInto(input, '/review this'))
+ await act(async () => required('button[aria-label="Send"]').click())
+ await act(async () => typeInto(input, '/review this now'))
+ await act(async () => typeInto(input, '/review this'))
+ await act(async () => resolveSkills([]))
+ await settleQueries()
+ await act(async () => {})
+ expect(mocks.submit).not.toHaveBeenCalled()
+})
+
+it('sends nothing once the composer has unmounted', async () => {
+ let resolveSkills: (skills: unknown[]) => void = () => {}
+ mocks.skillQuery.mockReturnValue(
+ new Promise((resolve) => {
+ resolveSkills = resolve
+ })
+ )
+ await render(true, '', 'agent')
+ const input = required('[aria-label="Ask Sim"]')
+ await act(async () => typeInto(input, '/review this'))
+ await act(async () => required('button[aria-label="Send"]').click())
+ await act(async () => root.unmount())
+ root = createRoot(container)
+ await act(async () => resolveSkills([]))
+ await act(async () => {})
+ expect(mocks.submit).not.toHaveBeenCalled()
+})
+
+it('holds for the workspace list too, then chips against the skills it names', async () => {
+ mocks.workspacesLoading = true
+ await render(true, '', 'agent')
+ const input = required('[aria-label="Ask Sim"]')
+ await act(async () => typeInto(input, '/review this'))
+ await act(async () => required('button[aria-label="Send"]').click())
+ await settleQueries()
+ await vi.waitFor(async () => {
+ await act(async () => {})
+ expect(mocks.contexts).toHaveBeenLastCalledWith([
+ { kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
+ ])
+ })
+ expect(mocks.skillQuery).toHaveBeenCalledExactlyOnceWith('workspace-a')
+})
diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx
deleted file mode 100644
index 3d9248a4d38..00000000000
--- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx
+++ /dev/null
@@ -1,392 +0,0 @@
-'use client'
-
-import { type ReactNode, useEffect, useRef } from 'react'
-import {
- Chip,
- ComposerActionButton,
- cn,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuTrigger,
- Tooltip,
- toast,
-} from '@sim/emcn'
-import { ArrowUp, Paperclip, Plus, Search, Slash, StopFilled } from '@sim/emcn/icons'
-import { useQueries } from '@tanstack/react-query'
-import { UNAVAILABLE_CONTEXT_NOTICE } from '@/lib/mothership/chat/context'
-import { ASSISTANT_IMAGE_ACCEPT_ATTRIBUTE } from '@/lib/uploads/shared/assistant-images'
-import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
-import { inter } from '@/app/_styles/fonts/inter/inter'
-import { SearchInputBar } from '@/app/o/[organizationId]/components/search-input-bar'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { AttachedFilesList } from '@/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list'
-import { DropOverlay } from '@/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/drop-overlay'
-import { InputToolbar } from '@/app/workspace/[workspaceId]/home/components/user-input/components/input-toolbar'
-import { MicButton } from '@/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button'
-import { MicrophonePermissionHelp } from '@/app/workspace/[workspaceId]/home/components/user-input/components/microphone-permission-help/microphone-permission-help'
-import type { ModelSelectorOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-selector'
-import {
- PromptEditor,
- usePromptEditor,
-} from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor'
-import { organizationSkillOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/organization-skill-options'
-import { usePromptHistory } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-prompt-history'
-import type { ChatMessage, ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types'
-import type { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
-import { filterContextsPresentInMessage } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
-import { getSkillsQueryOptions } from '@/hooks/queries/skills'
-import { useWorkspacesQuery } from '@/hooks/queries/workspace'
-import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
-import { useChatInputFocus } from '@/hooks/use-chat-input-focus'
-import type {
- PendingComposerSubmission,
- useComposerSubmission,
-} from '@/hooks/use-composer-submission'
-import { useVoiceInput } from '@/hooks/use-voice-input'
-import type { ChatContext } from '@/stores/panel'
-
-interface ComposerProps {
- requestMode?: ChatRequestMode
- onPlanChange?: () => void
- value: string
- files: ReturnType
- submission: ReturnType
- /** On the empty home the placeholder types itself and the field is taller; in a chat it is the plain footer input. */
- isInitialView: boolean
- isSending: boolean
- onChange: (value: string, contexts?: ChatContext[]) => void
- restoredContexts?: ChatContext[]
- onSubmit: (
- text: string,
- contexts?: ChatContext[]
- ) => undefined | false | PendingComposerSubmission
- onSendQueuedHead?: () => void
- onEditQueuedTail?: () => boolean
- messages?: readonly ChatMessage[]
- onStop: () => void
- compact?: boolean
- attachments?: ReactNode
- resourceActions?: ReactNode
- toolbarActions?: ReactNode
- modelSelectorOptions?: ModelSelectorOptions
-}
-
-/** Organization composer with scoped resources, attachments, voice, and draft restoration. */
-export function Composer({
- requestMode = 'assistant',
- onPlanChange,
- value,
- files,
- submission,
- isInitialView,
- isSending,
- onChange,
- onSubmit,
- onSendQueuedHead,
- onEditQueuedTail,
- messages,
- restoredContexts,
- onStop,
- compact = false,
- attachments,
- resourceActions,
- toolbarActions,
- modelSelectorOptions,
-}: ComposerProps) {
- const attachedFilesRef = useRef(files.attachedFiles)
- attachedFilesRef.current = files.attachedFiles
- const imagesOnly = requestMode === 'assistant'
- const searchInput = imagesOnly && !compact
- const hasAdditionalAttachments = Boolean(attachments)
- const { organization } = useOrganizationContext()
- const { data: allWorkspaces = [] } = useWorkspacesQuery(!imagesOnly)
- const workspaces = (imagesOnly ? [] : allWorkspaces).filter(
- (workspace) => workspace.organizationId === organization.id
- )
- const skillQueries = useQueries({
- queries: workspaces.map((workspace) => getSkillsQueryOptions(workspace.id)),
- })
- const skills = imagesOnly
- ? []
- : organizationSkillOptions(
- workspaces.map((workspace, index) => ({
- ...workspace,
- skills: skillQueries[index].isPlaceholderData ? [] : (skillQueries[index].data ?? []),
- }))
- )
- const editor = usePromptEditor({
- workspaceId: '',
- availableSkills: skills,
- organizationId: organization.id,
- contextsEnabled: !imagesOnly,
- initialValue: value,
- initialContexts: restoredContexts,
- onPasteFiles: files.processFiles,
- })
- const history = usePromptHistory({
- editor,
- attachedFiles: files.attachedFiles,
- restoreAttachedFiles: files.restoreAttachedFiles,
- getMessages: () => messages ?? [],
- })
- const { textareaRef } = editor
- const editorRef = useRef(editor)
- editorRef.current = editor
- const lastPublished = useRef(value)
- useEffect(() => {
- if (value !== lastPublished.current) {
- editorRef.current.setValue(value)
- if (!value) editorRef.current.setContexts([])
- lastPublished.current = value
- if (value) textareaRef.current?.focus()
- }
- }, [value, textareaRef])
- const lastContexts = useRef(editor.contexts)
- useEffect(() => {
- if (editorRef.current.getValue() !== editor.value) return
- const plainValue = editorRef.current.getPlainValue()
- if (plainValue !== lastPublished.current || editor.contexts !== lastContexts.current) {
- lastPublished.current = plainValue
- lastContexts.current = editor.contexts
- onChange(plainValue, editor.contexts.length ? editor.contexts : undefined)
- }
- }, [editor.value, editor.contexts, onChange])
- useEffect(() => {
- if (!restoredContexts) return
- history.reset()
- const { hasUnavailableContexts } = editorRef.current.restorePrompt(
- editorRef.current.getPlainValue(),
- restoredContexts
- )
- if (hasUnavailableContexts) toast.info(UNAVAILABLE_CONTEXT_NOTICE)
- }, [restoredContexts, history.reset])
- useChatInputFocus({ textareaRef })
- const voice = useVoiceInput({
- organizationId: organization.id,
- getValue: () => editor.getPlainValue(),
- onChange: (text) => editor.setValue(text),
- })
- const canSubmit =
- !files.attachedFiles.some((file) => file.uploading) &&
- (value.trim().length > 0 ||
- files.attachedFiles.some((file) => file.key) ||
- hasAdditionalAttachments)
- const animatedPlaceholder = useAnimatedPlaceholder(
- !compact && !imagesOnly && isInitialView && !value
- )
- const placeholder = isInitialView && !compact ? animatedPlaceholder : 'Send message to Sim'
-
- const { pending: preparingSubmission, accept: acceptSubmission } = submission
- const submit = () => {
- if (attachedFilesRef.current.some((file) => file.uploading)) return
- const hasPayload =
- editor.getValue().trim().length > 0 ||
- attachedFilesRef.current.some((file) => file.key) ||
- hasAdditionalAttachments
- if (!hasPayload) {
- if (isSending) onSendQueuedHead?.()
- return
- }
- const contexts = imagesOnly
- ? filterContextsPresentInMessage(
- editor.contexts.filter((context) => context.kind === 'browser_page'),
- editor.getPlainValue()
- )
- : editor.getActiveContexts()
- const raw = editor.getValue()
- const filesAtSend = attachedFilesRef.current
- acceptSubmission(
- () => onSubmit(editor.getPlainValue(), contexts.length ? contexts : undefined),
- () => {
- attachedFilesRef.current = []
- voice.resetTranscript()
- history.reset()
- editor.clear()
- },
- () => editorRef.current.getValue() === raw && attachedFilesRef.current === filesAtSend,
- textareaRef.current
- )
- }
-
- const contextPicker = (kind: 'resources' | 'skills', icon: typeof Plus, label: string) => (
-
-
- {
- if (kind === 'skills') editor.insertSlashTrigger()
- else {
- const rect = event.currentTarget.getBoundingClientRect()
- editor.openResourceMenu({ left: rect.left, top: rect.top })
- }
- }}
- />
-
- {label}
-
- )
-
- const recallQueuedTail = () => {
- if (hasAdditionalAttachments || attachedFilesRef.current.length > 0) return false
- return onEditQueuedTail?.() ?? false
- }
- const promptEditor = (
-
- )
-
- const leadingControls = (
- <>
- {searchInput && }
- {imagesOnly && resourceActions && (
-
-
-
-
- {resourceActions}
-
- )}
- {!imagesOnly && contextPicker('resources', Plus, 'Add resources')}
-
- {(!imagesOnly || compact) && (
-
-
-
-
-
- {imagesOnly ? 'Attach image' : 'Attach file'}
-
-
- )}
- {!imagesOnly && contextPicker('skills', Slash, 'Skills')}
- {toolbarActions}
- {onPlanChange && (
-
- Plan
-
- )}
- >
- )
- const voiceControl = voice.isSupported && (
-
- )
- const submitControl = isSending ? (
-
-
-
- ) : (
-
-
-
- )
-
- const attachmentList =
- files.attachedFiles.length || attachments ? (
-
- ) : null
-
- return (
-
- {!searchInput && attachmentList}
- {!searchInput && promptEditor}
-
- {searchInput ? (
- editor.setValue(text)}
- onSubmit={submit}
- onPaste={editor.handlePaste}
- onKeyDown={(event) =>
- editor.handleKeyDown(event, {
- onNavigateHistory: hasAdditionalAttachments ? undefined : history.navigate,
- onArrowUpOnEmpty: recallQueuedTail,
- })
- }
- placeholder={isInitialView ? 'Ask about your sources' : 'Ask a follow-up'}
- aria-label='Ask Sim'
- leadingControls={leadingControls}
- voiceControl={voiceControl}
- submitControl={submitControl}
- />
- ) : (
-
- )}
-
-
- {files.isDragging && }
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/index.ts b/apps/sim/app/o/[organizationId]/home/components/composer/index.ts
index c99ba66e037..bba55f94365 100644
--- a/apps/sim/app/o/[organizationId]/home/components/composer/index.ts
+++ b/apps/sim/app/o/[organizationId]/home/components/composer/index.ts
@@ -1 +1,6 @@
-export { Composer } from './composer'
+export {
+ COMPOSER_FIELD_HEIGHT_CLASSES,
+ ComposerAttachments,
+ ComposerFileTargets,
+} from './composer-parts'
+export { type ConversationComposerProps, useComposer } from './use-composer'
diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/use-composer.ts b/apps/sim/app/o/[organizationId]/home/components/composer/use-composer.ts
new file mode 100644
index 00000000000..dcac87cb440
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/components/composer/use-composer.ts
@@ -0,0 +1,298 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { toast } from '@sim/emcn'
+import { useQueries, useQueryClient } from '@tanstack/react-query'
+import { flushSync } from 'react-dom'
+import { UNAVAILABLE_CONTEXT_NOTICE } from '@/lib/mothership/chat/context'
+import { useOrganization } from '@/lib/organizations/provider'
+import { usePromptEditor } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor'
+import { organizationSkillOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/organization-skill-options'
+import { usePromptHistory } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-prompt-history'
+import type { ChatMessage, ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types'
+import type { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
+import { filterContextsPresentInMessage } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
+import { getSkillsQueryOptions } from '@/hooks/queries/skills'
+import { fetchWorkspacesList, useWorkspacesQuery } from '@/hooks/queries/workspace'
+import { useChatInputFocus } from '@/hooks/use-chat-input-focus'
+import type {
+ PendingComposerSubmission,
+ useComposerSubmission,
+} from '@/hooks/use-composer-submission'
+import { useVoiceInput } from '@/hooks/use-voice-input'
+import { readDraft, useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
+import type { ChatContext } from '@/stores/panel'
+
+/** What a conversation hands whichever composer view its surface draws. */
+export interface ConversationComposerProps {
+ requestMode: ChatRequestMode
+ onPlanChange?: () => void
+ /** Where the draft lives: written to the first key, read through the rest. */
+ draftKeys: readonly string[]
+ files: ReturnType
+ submission: ReturnType
+ /** On the empty home the placeholder types itself and the field is taller; in a chat it is the plain footer input. */
+ isInitialView: boolean
+ isSending: boolean
+ restoredContexts: ChatContext[]
+ onSubmit: (
+ text: string,
+ contexts?: ChatContext[]
+ ) => undefined | false | PendingComposerSubmission
+ onSendQueuedHead: () => void
+ onEditQueuedTail: () => boolean
+ messages: readonly ChatMessage[]
+ onStop: () => void
+}
+
+interface UseComposerOptions extends ConversationComposerProps {
+ /** The full page loads lists on intent and holds a `/name` until they land; the panel loads them on open and never holds. */
+ waitsForFullLists: boolean
+ /** Attachments the view shows itself, which count as something to send. */
+ hasAdditionalAttachments?: boolean
+}
+
+/** A `/` starting a token: typed, pasted, or carried in by a draft, all of which skills can chip. */
+const SLASH_TOKEN = /(^|\s)\//
+/**
+ * How long a send waits on the skill lists. The request layer sets no deadline, so this is the only
+ * bound on a list that never lands, and the message waits in the field when it passes.
+ */
+const SKILL_LISTS_WAIT_MS = 10_000
+
+/** A send waiting for the skill lists, so a `/name` in it is not sent as plain text. */
+interface HeldSend {
+ text: string
+ /** Editing the text aborts this, ending the wait. */
+ cancel: AbortController
+}
+
+function whenAborted(signal: AbortSignal) {
+ return new Promise((resolve) => {
+ signal.addEventListener('abort', () => resolve(), { once: true })
+ })
+}
+
+/**
+ * The organization composer's behavior: editing, draft sync, voice, the skill lists, and submission.
+ * Its two views draw it, the extension's panel and the page's. This module stays free of the design
+ * system so the extension can bundle it.
+ */
+export function useComposer({
+ requestMode,
+ draftKeys,
+ files,
+ submission,
+ isSending,
+ restoredContexts,
+ onSubmit,
+ onSendQueuedHead,
+ onEditQueuedTail,
+ messages,
+ waitsForFullLists,
+ hasAdditionalAttachments = false,
+}: UseComposerOptions) {
+ const [draftKey] = draftKeys
+ const value = useMothershipDraftsStore((state) => readDraft(state.drafts, draftKeys)?.text ?? '')
+ const imagesOnly = requestMode === 'assistant'
+ const { organization } = useOrganization()
+ const queryClient = useQueryClient()
+ const { data: allWorkspaces = [], isPending: workspacesPending } = useWorkspacesQuery(!imagesOnly)
+ const waitsForLists = waitsForFullLists && !imagesOnly
+ const workspaces = (imagesOnly ? [] : allWorkspaces).filter(
+ (workspace) => workspace.organizationId === organization.id
+ )
+ // The field takes focus on load, so focus alone says nothing about wanting these per-workspace lists.
+ const [resourceIntent, setResourceIntent] = useState(false)
+ const [heldSend, setHeldSend] = useState(null)
+ /** The text the viewer was told would go out unchipped if they sent it again. */
+ const [declinedWaitText, setDeclinedWaitText] = useState(null)
+ if (declinedWaitText !== null && value !== declinedWaitText) setDeclinedWaitText(null)
+ const skillsWanted =
+ !waitsForFullLists || resourceIntent || heldSend !== null || SLASH_TOKEN.test(value)
+ const skillQueries = useQueries({
+ queries: workspaces.map((workspace) => ({
+ ...getSkillsQueryOptions(workspace.id),
+ enabled: skillsWanted,
+ })),
+ })
+ const skills = imagesOnly
+ ? []
+ : organizationSkillOptions(
+ workspaces.map((workspace, index) => ({
+ ...workspace,
+ skills: skillQueries[index].isPlaceholderData ? [] : (skillQueries[index].data ?? []),
+ }))
+ )
+ const skillListsPending =
+ (waitsForLists && workspacesPending) || skillQueries.some((query) => query.isPending)
+ const editor = usePromptEditor({
+ workspaceId: '',
+ availableSkills: skills,
+ skillsLoading: waitsForFullLists && skillsWanted && skillListsPending,
+ waitsForFullResources: waitsForLists,
+ resourceIntent,
+ onResourceIntent: () => setResourceIntent(true),
+ organizationId: organization.id,
+ contextsEnabled: !imagesOnly,
+ initialValue: value,
+ initialContexts: restoredContexts,
+ onPasteFiles: files.processFiles,
+ })
+ const history = usePromptHistory({
+ editor,
+ attachedFiles: files.attachedFiles,
+ restoreAttachedFiles: files.restoreAttachedFiles,
+ getMessages: () => messages,
+ })
+ const { textareaRef, setValue, setContexts, getValue, getPlainValue, restorePrompt } = editor
+ /**
+ * The editor holds chips, the draft holds plain text, so neither can be the other's value. A write
+ * from outside, a queued message restored or a chat cleared, reaches the editor here.
+ */
+ useEffect(() => {
+ if (value === getPlainValue()) return
+ setValue(value)
+ if (value) textareaRef.current?.focus()
+ else setContexts([])
+ }, [value, textareaRef, setValue, setContexts, getPlainValue])
+ useEffect(() => {
+ if (getValue() !== editor.value) return
+ const text = getPlainValue()
+ const contexts = editor.contexts.length ? editor.contexts : undefined
+ const store = useMothershipDraftsStore.getState()
+ const saved = readDraft(store.drafts, draftKeys)
+ if (text === (saved?.text ?? '') && contexts === saved?.contexts) return
+ store.setDraft(draftKey, { ...store.drafts[draftKey], text, contexts })
+ }, [editor.value, editor.contexts, draftKeys, draftKey, getValue, getPlainValue])
+ useEffect(() => {
+ history.reset()
+ const { hasUnavailableContexts } = restorePrompt(getPlainValue(), restoredContexts)
+ if (hasUnavailableContexts) toast.info(UNAVAILABLE_CONTEXT_NOTICE)
+ }, [restoredContexts, history.reset, restorePrompt, getPlainValue])
+ useChatInputFocus({ textareaRef })
+ const voice = useVoiceInput({
+ organizationId: organization.id,
+ getValue: getPlainValue,
+ onChange: (text) => setValue(text),
+ })
+ const hasPayload = (text: string, attached: typeof files.attachedFiles) =>
+ text.trim().length > 0 || attached.some((file) => file.key) || hasAdditionalAttachments
+ const canSubmit =
+ !files.attachedFiles.some((file) => file.uploading) && hasPayload(value, files.attachedFiles)
+
+ const { pending: preparingSubmission, accept: acceptSubmission } = submission
+ const send = () => {
+ const filesAtSend = files.getAttachedFiles()
+ if (filesAtSend.some((file) => file.uploading)) return
+ if (!hasPayload(getValue(), filesAtSend)) {
+ if (isSending) onSendQueuedHead()
+ return
+ }
+ const contexts = imagesOnly
+ ? filterContextsPresentInMessage(
+ editor.contexts.filter((context) => context.kind === 'browser_page'),
+ getPlainValue()
+ )
+ : editor.getActiveContexts()
+ const raw = getValue()
+ acceptSubmission(
+ () => onSubmit(getPlainValue(), contexts.length ? contexts : undefined),
+ () => {
+ voice.resetTranscript()
+ history.reset()
+ editor.clear()
+ },
+ () => getValue() === raw && files.getAttachedFiles() === filesAtSend,
+ textareaRef.current
+ )
+ }
+
+ /** A continuation resumes after renders it did not see, so it sends through the latest closure. */
+ const sendRef = useRef(send)
+ sendRef.current = send
+
+ const settleSkillLists = async () => {
+ const pendingIds = workspacesPending
+ ? (await fetchWorkspacesList(queryClient))
+ .filter((workspace) => workspace.organizationId === organization.id)
+ .map((workspace) => workspace.id)
+ : workspaces.flatMap((workspace, index) =>
+ skillQueries[index].isPending ? [workspace.id] : []
+ )
+ const results = await Promise.allSettled(
+ pendingIds.map((id) => queryClient.fetchQuery(getSkillsQueryOptions(id)))
+ )
+ return results.every((result) => result.status === 'fulfilled')
+ }
+
+ const holdSend = async (text: string) => {
+ const cancel = new AbortController()
+ setHeldSend({ text, cancel })
+ const timeout = AbortSignal.timeout(SKILL_LISTS_WAIT_MS)
+ const allLanded = await Promise.race([
+ settleSkillLists().catch(() => false),
+ whenAborted(timeout).then(() => false),
+ whenAborted(cancel.signal).then(() => false),
+ ])
+ if (cancel.signal.aborted || !textareaRef.current || getPlainValue() !== text) return
+ // Query observers notify on their own schedule. Render now, so the editor has the lists to chip with.
+ flushSync(() => setHeldSend({ text, cancel }))
+ flushSync(() => setValue(text))
+ setHeldSend(null)
+ if (!allLanded && SLASH_TOKEN.test(getValue())) {
+ setDeclinedWaitText(text)
+ toast.error(
+ "Your skills didn't load, so this wasn't sent. Send again to send it as plain text."
+ )
+ return
+ }
+ sendRef.current()
+ }
+
+ const submit = () => {
+ const text = getPlainValue()
+ if (waitsForLists && skillListsPending && SLASH_TOKEN.test(text) && text !== declinedWaitText) {
+ if (heldSend?.text !== text) void holdSend(text)
+ return
+ }
+ send()
+ }
+ useEffect(() => {
+ if (!heldSend || getPlainValue() === heldSend.text) return
+ heldSend.cancel.abort()
+ setHeldSend(null)
+ }, [heldSend, editor.value, getPlainValue])
+
+ const openResourceMenu = (event: { currentTarget: Element }) => {
+ const rect = event.currentTarget.getBoundingClientRect()
+ editor.openResourceMenu({ left: rect.left, top: rect.top })
+ }
+ const recallQueuedTail = () => {
+ if (hasAdditionalAttachments || files.getAttachedFiles().length > 0) return false
+ return onEditQueuedTail()
+ }
+
+ return {
+ editor,
+ voice,
+ imagesOnly,
+ value,
+ canSend: canSubmit && !heldSend,
+ submit,
+ openResourceMenu,
+ /** Enter and the arrow keys' policy, shared by the prompt editor and the plain Assistant field. */
+ keyPolicy: {
+ onNavigateHistory: hasAdditionalAttachments ? undefined : history.navigate,
+ onArrowUpOnEmpty: recallQueuedTail,
+ },
+ frameProps: {
+ 'aria-busy': preparingSubmission || Boolean(heldSend) || undefined,
+ inert: preparingSubmission,
+ onDragEnter: files.handleDragEnter,
+ onDragLeave: files.handleDragLeave,
+ onDragOver: files.handleDragOver,
+ onDrop: files.handleDrop,
+ },
+ }
+}
diff --git a/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx b/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx
deleted file mode 100644
index 42f8f800cac..00000000000
--- a/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx
+++ /dev/null
@@ -1,225 +0,0 @@
-'use client'
-
-import { useEffect, useState } from 'react'
-import { cn } from '@sim/emcn'
-import { ArrowRight } from '@sim/emcn/icons'
-import { HomeSection } from '@/components/home/home-section'
-import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link'
-import { OAUTH_SEARCH_READ_SCOPE, oauthScopeSatisfies } from '@/lib/auth/oauth-provider'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import {
- liveSearchProviderForCredential,
- supportsLiveSearchMode,
-} from '@/lib/sim-search/live/provider-catalog'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { useAuthorizedApps } from '@/hooks/queries/oauth-provider'
-import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts'
-import { useSearchIntegrations } from '@/hooks/queries/search-integrations'
-
-type StepId = 'connect-integration' | 'connect-sim-search'
-
-interface GetStartedStep {
- id: StepId
- label: string
-}
-
-/** The onboarding steps, in the order a new organization works through them. */
-const STEPS: readonly GetStartedStep[] = [
- { id: 'connect-integration', label: 'Connect an integration' },
- { id: 'connect-sim-search', label: 'Connect Sim Search MCP' },
-]
-
-const ROW_CLASS =
- 'flex items-center gap-2 border-[var(--border)] px-2 py-2 text-left transition-colors hover-hover:bg-[var(--surface-5)]'
-
-/**
- * A step's leading mark: an empty ring until the step is done, then the
- * completion blue filled behind a check. The check is drawn here at the ring's
- * own scale rather than with the 24-unit house icon — scaled to 10px, that
- * stroke thins to a hair and its optical center drifts above the box. The svg
- * fills the ring's 14px content box (16px less the 1px border on each side), so
- * the path's center is the ring's center, and its stroke lands at ~1px — the
- * weight the house icons render at 16px.
- */
-function StepMark({ complete }: { complete: boolean }) {
- return (
-
- {complete && (
-
-
-
- )}
-
- )
-}
-
-/**
- * The organization home's onboarding list under the composer. Same chrome as
- * the workspace home's suggested actions: a hover-revealed disclosure header
- * over hairline-separated rows. Each step leads to the page that completes it,
- * and reads as done from the organization's real state: a configured integration and an OAuth app authorized to use Search.
- */
-export function GetStarted() {
- const { organization, viewer, connectedAccountsAvailable } = useOrganizationContext()
- const routes = organizationRoutes(organization.id)
- const canConnectIntegrations = viewer.canConnectSearchIntegrations && connectedAccountsAvailable
- const { data: accounts } = useOrganizationAccounts(
- canConnectIntegrations ? organization.id : undefined
- )
- const { data: integrations } = useSearchIntegrations(organization.id, {
- enabled: canConnectIntegrations,
- })
- const {
- data: authorizedApps,
- fetchNextPage,
- hasNextPage,
- isFetching,
- isError,
- } = useAuthorizedApps('', { enabled: viewer.canUseSearchMcp })
- const hasSearchAuthorization =
- authorizedApps?.pages.some((page) =>
- page.apps.some((app) => oauthScopeSatisfies(app.scopes, OAUTH_SEARCH_READ_SCOPE))
- ) ?? false
- const approvedProviders = new Set(
- integrations
- ?.filter((integration) => integration.approved && integration.available !== false)
- .map((integration) => integration.connectorType)
- )
- const readyOptions = new Set(
- accounts?.credentialGroup?.options
- .filter((option) => {
- const provider = liveSearchProviderForCredential(option.provider)
- return (
- option.status === 'active' &&
- option.configurationStatus === 'ready' &&
- provider &&
- supportsLiveSearchMode(provider, 'member') &&
- approvedProviders.has(provider)
- )
- })
- .map((option) => option.id)
- )
- const readyMcpServers = new Set(
- accounts?.credentialGroup?.mcpServers
- .filter((server) => {
- const provider = liveSearchProviderForCredential(`mcp:${server.managedConnectorId}`)
- return (
- server.enabled &&
- provider &&
- approvedProviders.has(provider) &&
- accounts.availableMcpConnectors.some((id) => id === server.managedConnectorId)
- )
- })
- .map((server) => server.id)
- )
- const hasSearchConnection =
- accounts?.credentialGroup?.status === 'active' &&
- (accounts.viewerAccounts?.some(
- (account) => account.status === 'active' && readyOptions.has(account.optionId)
- ) ||
- accounts.viewerMcpAccounts?.some(
- (account) => account.status === 'active' && readyMcpServers.has(account.mcpServerId)
- ))
-
- const hrefs: Record = {
- 'connect-integration': viewer.isAdmin
- ? routes.settingsSection('integrations')
- : routes.integrations,
- 'connect-sim-search': routes.settingsSection('search-mcp'),
- }
- const completed: Record = {
- 'connect-integration': Boolean(
- hasSearchConnection ||
- integrations?.some(
- (integration) =>
- integration.approved &&
- integration.available !== false &&
- integration.configuredServiceSource
- )
- ),
- 'connect-sim-search': hasSearchAuthorization,
- }
- const steps = STEPS.filter((step) =>
- step.id === 'connect-sim-search' ? viewer.canUseSearchMcp : canConnectIntegrations
- )
-
- const [expanded, setExpanded] = useState(true)
- /**
- * Collapsible animations are enabled only after the first user toggle, so
- * the initially-open, server-rendered panel appears at full height on first
- * paint instead of replaying the open animation and shifting the input
- * above it.
- */
- const [animationsEnabled, setAnimationsEnabled] = useState(false)
-
- useEffect(() => {
- if (
- viewer.canUseSearchMcp &&
- !hasSearchAuthorization &&
- hasNextPage &&
- !isFetching &&
- !isError
- ) {
- void fetchNextPage()
- }
- }, [
- viewer.canUseSearchMcp,
- hasSearchAuthorization,
- hasNextPage,
- isFetching,
- isError,
- fetchNextPage,
- ])
-
- const handleToggleExpanded = () => {
- setAnimationsEnabled(true)
- setExpanded((prev) => !prev)
- }
-
- if (steps.length === 0) return null
-
- return (
-
- {steps.map((step, i) => {
- const complete = completed[step.id]
- return (
- 0 && 'border-t')}
- >
-
-
- {step.label}
-
-
-
- )
- })}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/home/components/get-started/index.ts b/apps/sim/app/o/[organizationId]/home/components/get-started/index.ts
deleted file mode 100644
index 105c611b0ac..00000000000
--- a/apps/sim/app/o/[organizationId]/home/components/get-started/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { GetStarted } from './get-started'
diff --git a/apps/sim/app/o/[organizationId]/home/home-redirect.ts b/apps/sim/app/o/[organizationId]/home/home-redirect.ts
deleted file mode 100644
index 597717276df..00000000000
--- a/apps/sim/app/o/[organizationId]/home/home-redirect.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
-import type { OrganizationSurfaceContext } from '@/lib/organizations/surface'
-
-export function getOrganizationHomeRedirect(
- context: Pick,
- organizationId: string
-): string | null {
- if (context.mothershipAvailable && context.canBuild) return null
- return context.searchAccess.memberScoped
- ? organizationRoutes(organizationId).search
- : WORKSPACE_SETTINGS_PATH
-}
diff --git a/apps/sim/app/o/[organizationId]/home/loading.tsx b/apps/sim/app/o/[organizationId]/home/loading.tsx
new file mode 100644
index 00000000000..b5de305a773
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/loading.tsx
@@ -0,0 +1,6 @@
+import { HomeDashboardSkeleton } from '@/platform/app/(shell)/home/_components/home-dashboard/home-dashboard-skeleton'
+
+/** A route boundary cannot know the viewer's mode, so it holds the Assistant's, the common one. */
+export default function OrganizationHomeLoading() {
+ return
+}
diff --git a/apps/sim/app/o/[organizationId]/home/page.test.tsx b/apps/sim/app/o/[organizationId]/home/page.test.tsx
index 963f9a76697..cc2f4a76e92 100644
--- a/apps/sim/app/o/[organizationId]/home/page.test.tsx
+++ b/apps/sim/app/o/[organizationId]/home/page.test.tsx
@@ -8,19 +8,25 @@ import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const mocks = vi.hoisted(() => ({ context: vi.fn() }))
+const mocks = vi.hoisted(() => ({ context: vi.fn(), prefetchHome: vi.fn() }))
vi.mock('next/navigation', () => nextNavigationMock)
-vi.mock('@/lib/organizations/surface', () => ({ getOrganizationSurfaceContext: mocks.context }))
+vi.mock('@/lib/organizations/surface', () => ({ getOrganizationSurfaceGate: mocks.context }))
vi.mock('@/lib/mothership/chat/lifecycle', () => mothershipChatLifecycleMock)
-vi.mock('@/app/o/[organizationId]/integrations/integrations', () => ({
- OrganizationIntegrations: () => Integrations
,
+vi.mock('@/platform/lib/data/prefetch', () => ({}))
+vi.mock('@/platform/lib/data/prefetch-member-inventory', () => ({
+ prefetchHome: mocks.prefetchHome,
}))
-vi.mock('@/app/o/[organizationId]/search/search', () => ({
- OrganizationSearch: () => Standalone Search
,
+vi.mock('@/platform/app/(shell)/home/_components/home-dashboard/home-dashboard-skeleton', () => ({
+ HomeDashboardSkeleton: ({ requestMode }: { requestMode: string }) => (
+ Home for {requestMode}
+ ),
+}))
+vi.mock('@/platform/app/(shell)/search/_components/search-surface/search-surface', () => ({
+ OrganizationSearchSurface: () => Standalone Search
,
}))
-vi.mock('@/app/o/[organizationId]/components/organization-conversation', () => ({
- OrganizationConversation: ({ requestMode }: { requestMode?: string }) => (
+vi.mock('@/platform/app/(shell)/_components/conversation/page-conversation', () => ({
+ PageConversation: ({ requestMode }: { requestMode?: string }) => (
Organization Assistant
),
}))
@@ -33,6 +39,7 @@ import OrganizationSearchPage from '@/app/o/[organizationId]/search/page'
const mockChat = mothershipChatLifecycleMockFns.mockGetAccessibleCopilotChatAuth
const params = Promise.resolve({ organizationId: 'org-1', chatId: 'chat-1' })
+const searchParams = Promise.resolve({})
const session = { user: { id: 'viewer', name: 'Taylor' }, session: { id: 'session-1' } }
describe('organization Search page gates', () => {
@@ -48,7 +55,7 @@ describe('organization Search page gates', () => {
it.each([
['Home', () => OrganizationHomePage({ params })],
- ['Search', () => OrganizationSearchPage({ params })],
+ ['Search', () => OrganizationSearchPage({ params, searchParams })],
['chat', () => OrganizationChatPage({ params })],
['organization entry', () => OrganizationPage({ params })],
] as const)('denies %s to nonmembers before loading content', async (_name, open) => {
@@ -80,7 +87,47 @@ describe('organization Search page gates', () => {
expect(mockChat).not.toHaveBeenCalled()
})
- it('routes chat URLs exactly where Home routes the same viewer, before loading the chat', async () => {
+ const searchOnlyMember = {
+ mothershipAvailable: true,
+ canBuild: false,
+ searchAccess: { memberScoped: true },
+ }
+
+ it('gives a search-only member Home with the Assistant, at both of its addresses', async () => {
+ mocks.context.mockResolvedValue(searchOnlyMember)
+ expect(renderToStaticMarkup(await OrganizationHomePage({ params }))).toContain(
+ 'Home for assistant'
+ )
+ await expect(OrganizationPage({ params })).rejects.toThrow('NEXT_REDIRECT:/o/org-1/home')
+ })
+
+ it.each([true, false])(
+ 'gives a builder Home with the agent (Search access: %s)',
+ async (memberScoped) => {
+ mocks.context.mockResolvedValue({
+ mothershipAvailable: true,
+ canBuild: true,
+ searchAccess: { memberScoped },
+ })
+ expect(renderToStaticMarkup(await OrganizationHomePage({ params }))).toContain(
+ 'Home for agent'
+ )
+ await expect(OrganizationPage({ params })).rejects.toThrow('NEXT_REDIRECT:/o/org-1/home')
+ }
+ )
+
+ it('sends a member without the Assistant to Search, where Home has no conversation to hold', async () => {
+ mocks.context.mockResolvedValue({ ...searchOnlyMember, mothershipAvailable: false })
+ await expect(OrganizationHomePage({ params })).rejects.toThrow('NEXT_REDIRECT:/o/org-1/search')
+ await expect(OrganizationPage({ params })).rejects.toThrow('NEXT_REDIRECT:/o/org-1/search')
+ })
+
+ it('keeps a search-only member out of an agent chat that Home does not entitle them to', async () => {
+ mocks.context.mockResolvedValue(searchOnlyMember)
+ await expect(OrganizationChatPage({ params })).rejects.toThrow('NEXT_REDIRECT:/o/org-1/search')
+ })
+
+ it('sends a viewer with no conversation here away from chat URLs, before loading the chat', async () => {
mocks.context.mockResolvedValue({
mothershipAvailable: true,
canBuild: false,
diff --git a/apps/sim/app/o/[organizationId]/home/page.tsx b/apps/sim/app/o/[organizationId]/home/page.tsx
index ba9bde38fec..88f7f70d266 100644
--- a/apps/sim/app/o/[organizationId]/home/page.tsx
+++ b/apps/sim/app/o/[organizationId]/home/page.tsx
@@ -1,11 +1,13 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
-import { notFound, redirect } from 'next/navigation'
-import { getSession } from '@/lib/auth'
-import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
-import { OrganizationConversation } from '@/app/o/[organizationId]/components/organization-conversation'
-import { getOrganizationHomeRedirect } from '@/app/o/[organizationId]/home/home-redirect'
-import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
+import { redirect } from 'next/navigation'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { getOrganizationHomeRedirect } from '@/lib/organizations/home-redirect'
+import { requireOrganizationViewer } from '@/app/o/[organizationId]/viewer'
+import { HomeConversation } from '@/platform/app/(shell)/home/_components/home-conversation/home-conversation'
+import { HomeDashboardSkeleton } from '@/platform/app/(shell)/home/_components/home-dashboard/home-dashboard-skeleton'
+import { Hydrated } from '@/platform/design-system/components/surface/hydrated'
+import { prefetchHome } from '@/platform/lib/data/prefetch-member-inventory'
export const metadata: Metadata = {
title: 'Home',
@@ -17,16 +19,19 @@ export default async function OrganizationHomePage({
params: Promise<{ organizationId: string }>
}) {
const { organizationId } = await params
- const session = await getSession()
- if (!session?.user?.id) notFound()
- const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
- if (!context) notFound()
+ const { principal, surface: context } = await requireOrganizationViewer(
+ organizationId,
+ organizationRoutes(organizationId).home
+ )
const homeRedirect = getOrganizationHomeRedirect(context, organizationId)
if (homeRedirect) redirect(homeRedirect)
+ const requestMode = context.canBuild ? 'agent' : 'assistant'
return (
- }>
-
+ }>
+
+
+
)
}
diff --git a/apps/sim/app/o/[organizationId]/integrations/[provider]/loading.tsx b/apps/sim/app/o/[organizationId]/integrations/[provider]/loading.tsx
new file mode 100644
index 00000000000..82d4d88a55e
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/[provider]/loading.tsx
@@ -0,0 +1,10 @@
+import { IntegrationsRouteFallback } from '@/platform/app/(shell)/integrations/_components/integrations-page/integrations-route'
+import { PageSurface } from '@/platform/design-system/components/surface/surface'
+
+export default function IntegrationDetailLoading() {
+ return (
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/integrations/[provider]/page.tsx b/apps/sim/app/o/[organizationId]/integrations/[provider]/page.tsx
new file mode 100644
index 00000000000..a796bea4a4c
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/[provider]/page.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from 'next'
+import { requireIntegrationsViewer } from '@/app/o/[organizationId]/integrations/access'
+import { HydratedIntegrations } from '@/app/o/[organizationId]/integrations/hydrated-integrations'
+import { integrationPath } from '@/platform/lib/integrations/model'
+
+export const metadata: Metadata = {
+ title: 'Integrations',
+ referrer: 'no-referrer',
+}
+
+interface IntegrationProviderPageProps {
+ params: Promise<{ organizationId: string; provider: string }>
+}
+
+export default async function IntegrationProviderPage({ params }: IntegrationProviderPageProps) {
+ const { organizationId, provider } = await params
+ const viewer = await requireIntegrationsViewer(
+ organizationId,
+ integrationPath(organizationId, provider)
+ )
+ return
+}
diff --git a/apps/sim/app/o/[organizationId]/integrations/access.ts b/apps/sim/app/o/[organizationId]/integrations/access.ts
new file mode 100644
index 00000000000..d82c24dc372
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/access.ts
@@ -0,0 +1,12 @@
+import { notFound } from 'next/navigation'
+import { requireOrganizationViewer } from '@/app/o/[organizationId]/viewer'
+
+/**
+ * The gate the integrations index and provider pages share. `callbackUrl` is the page to come back to after
+ * sign-in, carrying whatever connection or Slack context the visit arrived with.
+ */
+export async function requireIntegrationsViewer(organizationId: string, callbackUrl: string) {
+ const { principal, surface } = await requireOrganizationViewer(organizationId, callbackUrl)
+ if (!surface.searchAccess.memberScoped) notFound()
+ return { principal, surface }
+}
diff --git a/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx b/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx
deleted file mode 100644
index 55ae8cbef3e..00000000000
--- a/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import {
- Chip,
- ChipConfirmModal,
- ChipModalError,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuItemLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@sim/emcn'
-import { MoreHorizontal } from '@sim/emcn/icons'
-import Link from 'next/link'
-import type { ViewerSearchSourceAccount } from '@/lib/api/contracts/knowledge/connectors'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { useDisconnectPersonalOrganizationAccount } from '@/hooks/queries/organization-accounts'
-
-interface AccountActionsMenuProps {
- organizationId: string
- integrationName: string
- accounts: ViewerSearchSourceAccount[]
- onConnect?: () => void
- onReconnect: (credentialId: string) => void
- connectionPending: boolean
- connecting: boolean
- connectionReady: boolean
- permissionAccountIds: readonly string[]
- reconnectAccountIds: readonly string[]
-}
-
-export function AccountActionsMenu({
- organizationId,
- integrationName,
- accounts,
- onConnect,
- onReconnect,
- connectionPending,
- connecting,
- connectionReady,
- permissionAccountIds,
- reconnectAccountIds,
-}: AccountActionsMenuProps) {
- const disconnect = useDisconnectPersonalOrganizationAccount()
- const [selectedId, setSelectedId] = useState(null)
- const selected = accounts.find((account) => account.credentialId === selectedId)
- const permissionAccounts = accounts.filter((account) =>
- permissionAccountIds.includes(account.credentialId)
- )
- const hasAccountActions = accounts.length > 0
- const pending = connectionPending || disconnect.isPending
- if (!accounts.length && !onConnect) return null
-
- return (
- <>
-
-
-
-
-
- {permissionAccounts.map((account) => (
-
-
- 1 ? ` · ${account.displayName}` : ''}`}
- />
-
-
- ))}
- {accounts.map((account) => (
- onReconnect(account.credentialId)}
- >
- Reconnect{accounts.length > 1 ? ` · ${account.displayName}` : ''}
-
- ))}
- {hasAccountActions && onConnect && }
- {onConnect && (
-
- {connecting ? 'Connecting' : accounts.length ? 'Add account' : 'Connect'}
-
- )}
- {hasAccountActions && }
- {accounts.map((account) => (
- {
- disconnect.reset()
- setSelectedId(account.credentialId)
- }}
- >
- Disconnect{accounts.length > 1 ? ` · ${account.displayName}` : ''}
-
- ))}
-
-
- {
- if (!open && !disconnect.isPending) setSelectedId(null)
- }}
- title={`Disconnect ${integrationName}`}
- text={`Disconnect ${selected?.displayName ?? integrationName} from all ${integrationName} connections in this organization. Workflows using this account will also lose access. You can reconnect later.`}
- confirm={{
- label: 'Disconnect',
- pendingLabel: 'Disconnecting',
- pending: disconnect.isPending,
- disabled: disconnect.isPending,
- onClick: () => {
- if (!selected || disconnect.isPending) return
- disconnect.mutate(
- { credentialId: selected.credentialId, organizationId },
- { onSuccess: () => setSelectedId(null) }
- )
- },
- }}
- >
- {disconnect.error?.message}
-
- >
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx b/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx
deleted file mode 100644
index f886a70644a..00000000000
--- a/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { ChipConfirmModal, ChipModalError } from '@sim/emcn'
-import { type SettingsAction, SettingsActionChips } from '@/components/settings/settings-header'
-import type { ViewerSearchSourceAccount } from '@/lib/api/contracts/knowledge/connectors'
-import { useDisconnectPersonalOrganizationAccount } from '@/hooks/queries/organization-accounts'
-
-interface AccountConnectionActionsProps {
- organizationId: string
- integrationName: string
- account?: ViewerSearchSourceAccount
- onConnect?: () => void
- connectLabel: 'Connect' | 'Add account'
- onReconnect: () => void
- canReconnect: boolean
- canConnect: boolean
- connectionPending: boolean
-}
-
-/** Header actions act on the selected account; adding a connection uses its available slot. */
-export function AccountConnectionActions({
- organizationId,
- integrationName,
- account,
- onConnect,
- connectLabel,
- onReconnect,
- canReconnect,
- canConnect,
- connectionPending,
-}: AccountConnectionActionsProps) {
- const disconnect = useDisconnectPersonalOrganizationAccount()
- const [confirming, setConfirming] = useState(false)
- const pending = connectionPending || disconnect.isPending
- const actions: SettingsAction[] = []
- if (account) {
- actions.push({
- id: 'reconnect',
- text: 'Reconnect',
- disabled: pending || !canReconnect,
- onSelect: onReconnect,
- })
- }
- if (onConnect) {
- actions.push({
- id: 'connect',
- text: connectLabel,
- variant: 'primary',
- disabled: pending || !canConnect,
- onSelect: onConnect,
- })
- }
- if (account) {
- actions.push({
- id: 'delete',
- text: 'Disconnect',
- disabled: pending,
- onSelect: () => {
- disconnect.reset()
- setConfirming(true)
- },
- })
- }
- return (
- <>
-
- {account && (
- {
- if (!disconnect.isPending) setConfirming(open)
- }}
- title={`Disconnect ${integrationName}`}
- text={`Disconnect ${account.displayName} from all ${integrationName} connections in this organization. Workflows using this account will also lose access. You can reconnect later.`}
- confirm={{
- label: 'Disconnect',
- pendingLabel: 'Disconnecting',
- pending: disconnect.isPending,
- disabled: disconnect.isPending,
- onClick: () =>
- disconnect.mutate(
- { credentialId: account.credentialId, organizationId },
- { onSuccess: () => setConfirming(false) }
- ),
- }}
- >
- {disconnect.error?.message}
-
- )}
- >
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/page.tsx b/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/page.tsx
index 42158de4d9d..211ca52c78e 100644
--- a/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/page.tsx
+++ b/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/page.tsx
@@ -1,7 +1,10 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
+import { organizationRoutes } from '@/lib/navigation/paths'
import Loading from '@/app/o/[organizationId]/integrations/accounts/[credentialId]/loading'
-import { AccountToolPermissions } from '@/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions'
+import { IntegrationsPage } from '@/platform/app/(shell)/integrations/_components/integrations-page/integrations-page'
+import { ToolPermissionsPanel } from '@/platform/app/(shell)/integrations/_components/tool-permissions/tool-permissions-panel'
+import { PageSurface } from '@/platform/design-system/components/surface/surface'
export const metadata: Metadata = { title: 'Tool permissions', referrer: 'no-referrer' }
@@ -12,9 +15,20 @@ interface AccountToolPermissionsPageProps {
export default async function AccountToolPermissionsPage({
params,
}: AccountToolPermissionsPageProps) {
+ const { organizationId, credentialId } = await params
return (
}>
-
+
+
+
+
+
)
}
diff --git a/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx b/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx
deleted file mode 100644
index 1dd4337492e..00000000000
--- a/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx
+++ /dev/null
@@ -1,237 +0,0 @@
-'use client'
-
-import { ChipInput, ChipLink, ChipSelect, ChipSwitch, Info, toast } from '@sim/emcn'
-import { ArrowLeft, Search } from '@sim/emcn/icons'
-import { useQueryStates } from 'nuqs'
-import type {
- AssistantToolPermissionsResponse,
- UpdateAssistantToolPermissionsBody,
-} from '@/lib/api/contracts/assistant-actions'
-import { resolveCredentialDisplay } from '@/lib/integrations/credential-display'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { integrationViewParsers } from '@/app/o/[organizationId]/integrations/search-params'
-import {
- CredentialDetailHeading,
- CredentialDetailLayout,
-} from '@/app/workspace/[workspaceId]/components/credential-detail'
-import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
-import {
- SettingsEmptyState,
- SettingsQueryErrorState,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
-import {
- useAssistantToolPermissions,
- useUpdateAssistantToolPermissions,
-} from '@/hooks/queries/assistant-actions'
-import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
-
-const POLICY_OPTIONS = [
- { value: 'allow', label: 'Allow' },
- { value: 'ask', label: 'Ask' },
- { value: 'block', label: 'Block' },
-] as const
-const TOOL_GROUPS = [
- { effect: 'read', label: 'Read-only tools' },
- { effect: 'write', label: 'Write tools' },
-] as const
-
-interface AccountToolPermissionsProps {
- organizationId: string
- credentialId: string
-}
-
-export function AccountToolPermissions({
- organizationId,
- credentialId,
-}: AccountToolPermissionsProps) {
- return (
-
- Integrations
-
- }
- >
-
-
- )
-}
-
-interface AccountToolPermissionsContentProps extends AccountToolPermissionsProps {
- showHeading?: boolean
-}
-
-export function AccountToolPermissionsContent({
- organizationId,
- credentialId,
- showHeading = false,
-}: AccountToolPermissionsContentProps) {
- const query = useAssistantToolPermissions(organizationId, credentialId)
- const update = useUpdateAssistantToolPermissions(organizationId, credentialId)
- const [{ tools: search }, setParams] = useQueryStates(integrationViewParsers)
- const setSearch = useDebouncedSearchSetter((value, options) =>
- setParams({ tools: value }, options)
- )
- const data = query.data
- const display = data
- ? resolveCredentialDisplay({ ...data.account, type: 'managed_oauth' })
- : undefined
- const save = (change: UpdateAssistantToolPermissionsBody['change']) => {
- if (!data || update.isPending) return
- update.mutate(
- { revision: data.revision, change },
- { onError: (error) => toast.error(error.message) }
- )
- }
-
- return (
- <>
- {query.error ? (
- void query.refetch()}
- variant='inline'
- />
- ) : !data ? (
- Loading tool permissions
- ) : (
-
- {(showHeading || !data.canAutoApprove || !data.account.active) && (
-
- {showHeading && (
-
- ) : undefined
- }
- />
- )}
- {!data.canAutoApprove && (
-
- Your organization requires approval before tools run.
-
- )}
- {!data.account.active && (
-
- This account is disconnected or paused. Reconnect it before allowing tools
- automatically.
-
- )}
-
- )}
- {data.tools.length > 0 && (
-
setSearch(event.target.value)}
- placeholder='Search tools'
- aria-label='Search tools'
- />
- )}
- {TOOL_GROUPS.map((group) => (
-
- ))}
- {data.tools.length === 0 && (
-
- No Assistant tools are available for this account
-
- )}
-
- )}
- >
- )
-}
-
-interface ToolPermissionGroupProps {
- effect: 'read' | 'write'
- label: string
- data: AssistantToolPermissionsResponse
- search: string
- pending: boolean
- onChange: (change: UpdateAssistantToolPermissionsBody['change']) => void
-}
-
-function ToolPermissionGroup({
- effect,
- label,
- data,
- search,
- pending,
- onChange,
-}: ToolPermissionGroupProps) {
- const tools = data.tools.filter((tool) => tool.effect === effect)
- const query = search.trim().toLowerCase()
- const visible = tools.filter((tool) =>
- `${tool.name} ${tool.description}`.toLowerCase().includes(query)
- )
- if (tools.length === 0) return null
- const groupValue = tools.every((tool) => tool.policy === tools[0].policy)
- ? tools[0].policy
- : undefined
- const options = POLICY_OPTIONS.map((option) => ({
- ...option,
- disabled: option.value === 'allow' && (!data.canAutoApprove || !data.account.active),
- }))
- return (
- {
- const option = POLICY_OPTIONS.find((entry) => entry.value === value)
- if (option) onChange({ kind: 'group', effect, policy: option.value })
- }}
- />
- }
- >
- {visible.map((tool) => (
-
-
- {tool.name}
- {tool.description && (
-
- {tool.description}
-
- )}
-
-
onChange({ kind: 'tool', toolId: tool.toolId, policy })}
- />
-
- ))}
- {!visible.length && (
- No matching tools
- )}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/integrations/assistant-account-integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/assistant-account-integrations.tsx
deleted file mode 100644
index eb976ef4196..00000000000
--- a/apps/sim/app/o/[organizationId]/integrations/assistant-account-integrations.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-'use client'
-
-import { ChipSelect, toast } from '@sim/emcn'
-import type { SearchIntegrationStatus } from '@/lib/api/contracts/knowledge/search-integrations'
-import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts'
-import { assistantSearchSourceForProvider } from '@/lib/assistant-actions/providers'
-import {
- getCredentialGroupProviderId,
- getCredentialGroupProviderService,
-} from '@/lib/credential-groups/providers'
-import { AccountActionsMenu } from '@/app/o/[organizationId]/integrations/account-actions-menu'
-import { assistantAccountOptions } from '@/app/o/[organizationId]/integrations/utils'
-import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
-import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
-import {
- useConnectOrganizationAccount,
- useEnsureOrganizationAccounts,
- useReconnectPersonalOrganizationAccount,
-} from '@/hooks/queries/organization-accounts'
-
-interface AssistantAccountIntegrationsProps {
- organizationId: string
- search: string
- data: OrganizationAccountsSettings
- policies: SearchIntegrationStatus[] | undefined
- admin?: boolean
-}
-
-/** Apps without a Search connector use the same personal enrollment and approval controls. */
-export function AssistantAccountIntegrations({
- organizationId,
- search,
- data,
- policies,
- admin = false,
-}: AssistantAccountIntegrationsProps) {
- const connect = useConnectOrganizationAccount()
- const reconnect = useReconnectPersonalOrganizationAccount()
- const ensure = useEnsureOrganizationAccounts()
- const onError = (error: Error) => toast.error(error.message)
- const group = data.credentialGroup
- const { providers, visible } = assistantAccountOptions(data, search)
- const query = search.trim().toLowerCase()
- const available = providers.filter(
- (provider) =>
- getCredentialGroupProviderService(provider).name.toLowerCase().includes(query) &&
- data.availableProviders.includes(provider) &&
- !group?.options.some((option) => option.provider === provider)
- )
- if (!visible.length && (!admin || !available.length)) return null
- return (
- ({
- value: provider,
- label: getCredentialGroupProviderService(provider).name,
- }))}
- onChange={(value) => {
- const provider = available.find((candidate) => candidate === value)
- if (provider && provider !== 'slack')
- ensure.mutate(
- {
- organizationId,
- option: {
- provider,
- label: getCredentialGroupProviderService(provider).name,
- required: false,
- },
- },
- { onError }
- )
- }}
- />
- ) : undefined
- }
- >
-
- {visible.map((option) => {
- const service = getCredentialGroupProviderService(option.provider)
- const accounts = (data.viewerAccounts ?? []).filter(
- (account) => account.optionId === option.id
- )
- const source = assistantSearchSourceForProvider(
- getCredentialGroupProviderId(option.provider)
- )
- const policy = policies?.find((entry) => entry.connectorType === source)
- const ready =
- group?.status === 'active' &&
- option.status === 'active' &&
- option.configurationStatus === 'ready' &&
- providers.includes(option.provider) &&
- data.availableProviders.includes(option.provider) &&
- (!source || (policy?.approved === true && policy.available !== false))
- return (
-
}
- title={service.name}
- description={
- accounts.length
- ? accounts.map((account) => account.displayName).join(', ')
- : source && !policies
- ? undefined
- : ready
- ? 'No account connected'
- : 'Connections unavailable'
- }
- trailing={
-
connect.mutate({ organizationId, optionId: option.id }, { onError })
- : undefined
- }
- onReconnect={(credentialId) => reconnect.mutate(credentialId, { onError })}
- connectionPending={connect.isPending || reconnect.isPending}
- connecting={
- connect.isPending &&
- 'optionId' in connect.variables &&
- connect.variables.optionId === option.id
- }
- connectionReady={ready}
- reconnectAccountIds={ready ? accounts.map((account) => account.credentialId) : []}
- permissionAccountIds={
- data.assistantProviders.includes(option.provider)
- ? accounts.map((account) => account.credentialId)
- : []
- }
- />
- }
- />
- )
- })}
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/integrations/hydrated-integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/hydrated-integrations.tsx
new file mode 100644
index 00000000000..c8a62de5de3
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/hydrated-integrations.tsx
@@ -0,0 +1,28 @@
+import { Suspense } from 'react'
+import type { SessionPrincipal } from '@sim/auth/principal'
+import type { OrganizationSurfaceGate } from '@/lib/organizations/surface'
+import {
+ IntegrationsRoute,
+ IntegrationsRouteFallback,
+ type IntegrationsRouteProps,
+} from '@/platform/app/(shell)/integrations/_components/integrations-page/integrations-route'
+import { Hydrated } from '@/platform/design-system/components/surface/hydrated'
+import { PageSurface } from '@/platform/design-system/components/surface/surface'
+import { prefetchIntegrations } from '@/platform/lib/data/prefetch-member-inventory'
+
+interface HydratedIntegrationsProps extends Omit {
+ surface: OrganizationSurfaceGate
+ principal: SessionPrincipal
+}
+
+export function HydratedIntegrations({ surface, principal, ...route }: HydratedIntegrationsProps) {
+ return (
+
+ }>
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx b/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx
deleted file mode 100644
index b7906e5cfb8..00000000000
--- a/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-'use client'
-
-import type { ReactNode } from 'react'
-import { toast } from '@sim/emcn'
-import { AccountConnectionActions } from '@/app/o/[organizationId]/integrations/account-connection-actions'
-import { AccountToolPermissionsContent } from '@/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions'
-import type { MemberIntegration } from '@/app/o/[organizationId]/integrations/utils'
-import {
- CredentialDetailHeading,
- CredentialDetailLayout,
-} from '@/app/workspace/[workspaceId]/components/credential-detail'
-import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
-import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field'
-import {
- RESOURCE_LIST_STACK,
- SettingsResourceRow,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
-import {
- useConnectOrganizationAccount,
- useReconnectPersonalOrganizationAccount,
-} from '@/hooks/queries/organization-accounts'
-
-interface IntegrationDetailProps {
- organizationId: string
- integration: MemberIntegration
- accountId: string | null
- accountHref: (accountId: string) => string
- back: ReactNode
- children?: ReactNode
-}
-
-/** The selected account owns every permission and connection action in this view. */
-export function IntegrationDetail({
- organizationId,
- integration,
- accountId,
- accountHref,
- back,
- children,
-}: IntegrationDetailProps) {
- const connect = useConnectOrganizationAccount()
- const reconnect = useReconnectPersonalOrganizationAccount()
- const onError = (error: Error) => toast.error(error.message)
- const selected = accountId
- ? integration.accounts.find((account) => account.credentialId === accountId)
- : integration.accounts.length === 1
- ? integration.accounts[0]
- : undefined
- const target = integration.connectionTarget
- return (
- connect.mutate({ organizationId, ...target }, { onError }) : undefined
- }
- onReconnect={() => {
- if (selected) reconnect.mutate(selected.credentialId, { onError })
- }}
- connectionPending={connect.isPending || reconnect.isPending}
- canConnect={integration.ready}
- canReconnect={Boolean(
- selected && integration.reconnectAccountIds.includes(selected.credentialId)
- )}
- />
- )
- }
- >
- {children}
- }
- />
- {integration.managed ? (
- Organization managed
- ) : (
-
- {accountId && !selected ? (
-
- This account is no longer connected.
-
- ) : selected ? (
-
- {selected.displayName}
-
- ) : integration.accounts.length ? (
-
- {integration.accounts.map((account) => (
-
- ))}
-
- ) : (
- No account connected
- )}
-
- )}
- {selected && integration.permissionAccountIds.includes(selected.credentialId) && (
-
- )}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx
deleted file mode 100644
index b85c2901409..00000000000
--- a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-'use client'
-
-import type { SearchConnectionTarget } from '@/lib/knowledge/search/connection-target'
-import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
-import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page'
-import { LiveMemberIntegrations } from '@/app/o/[organizationId]/integrations/live-member-integrations'
-import { SlackSearchActions } from '@/app/o/[organizationId]/integrations/slack-search-actions'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection'
-import { useDebounce } from '@/hooks/use-debounce'
-import { useDesktopOAuthConnectListener, useOAuthReturnRouter } from '@/hooks/use-oauth-return'
-
-interface OrganizationIntegrationsProps {
- connectionRequest?: { target: SearchConnectionTarget; userId: string }
- slackOnboarding?: { token: string; userId: string }
-}
-
-export function OrganizationIntegrations({
- slackOnboarding,
- connectionRequest,
-}: OrganizationIntegrationsProps = {}) {
- useOAuthReturnRouter()
- useDesktopOAuthConnectListener()
- const { organization } = useOrganizationContext()
- const { search } = useOrganizationPageFilters()
- const sourceSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS)
-
- return (
-
- )
- }
- intro={
- connectionRequest && (
-
- )
- }
- />
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx
deleted file mode 100644
index d8fc4bdf7f7..00000000000
--- a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-'use client'
-
-import type { ReactNode } from 'react'
-import { ChipLink } from '@sim/emcn'
-import { ArrowLeft } from '@sim/emcn/icons'
-import { useQueryStates } from 'nuqs'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page'
-import { IntegrationDetail } from '@/app/o/[organizationId]/integrations/integration-detail'
-import {
- integrationViewParsers,
- serializeIntegrationView,
-} from '@/app/o/[organizationId]/integrations/search-params'
-import {
- memberIntegrationEntries,
- memberIntegrationStatus,
-} from '@/app/o/[organizationId]/integrations/utils'
-import {
- GenericSecretSourceIcon,
- GenericSecretSourceRow,
-} from '@/app/o/[organizationId]/settings/components/integrations/generic-secret-source'
-import { CredentialDetailLayout } from '@/app/workspace/[workspaceId]/components/credential-detail'
-import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
-import {
- SettingsEmptyState,
- SettingsQueryErrorState,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import {
- RESOURCE_LIST_STACK,
- SettingsResourceRow,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts'
-import { useOrganizationSecretSource } from '@/hooks/queries/organization-secrets'
-import { useSearchIntegrations } from '@/hooks/queries/search-integrations'
-
-interface LiveMemberIntegrationsProps {
- organizationId: string
- search: string
- navigationSearch: string
- intro?: ReactNode
- action?: ReactNode
-}
-
-/** Member integrations share one inventory and the platform's list/detail layout. */
-export function LiveMemberIntegrations({
- organizationId,
- search,
- navigationSearch,
- intro,
- action,
-}: LiveMemberIntegrationsProps) {
- const [{ integration, account }] = useQueryStates(integrationViewParsers)
- const inventory = useOrganizationAccounts(organizationId)
- const policies = useSearchIntegrations(organizationId)
- const secrets = useOrganizationSecretSource(organizationId)
- const routes = organizationRoutes(organizationId)
- const href = (selected: string | null, selectedAccount: string | null = null) =>
- serializeIntegrationView(routes.integrations, {
- q: navigationSearch,
- integration: selected,
- account: selectedAccount,
- })
- const entries = inventory.data
- ? memberIntegrationEntries(inventory.data, policies.isSuccess ? policies.data : undefined)
- : []
- const selected = entries.find((entry) => entry.id === integration)
- const status = inventory.error ? (
- void inventory.refetch()}
- variant='inline'
- />
- ) : !inventory.data ? (
- Loading your connections
- ) : null
- const errors = (
- <>
- {policies.error && (
- void policies.refetch()}
- variant='inline'
- />
- )}
- {secrets.error && (
- void secrets.refetch()}
- variant='inline'
- />
- )}
- >
- )
- if (integration) {
- const back = (
-
- {account ? (selected?.name ?? 'Integration') : 'Integrations'}
-
- )
- if (selected && !status) {
- return (
- href(selected.id, credentialId)}
- back={back}
- >
- {errors}
-
- )
- }
- return (
-
- {status ?? (
- <>
- {errors}
- {integration === 'secrets' ? (
- secrets.data?.source ? (
-
- ) : (
-
- {secrets.isPending
- ? 'Loading connection'
- : 'This integration is no longer available.'}
-
- )
- ) : (
-
- {policies.isPending
- ? 'Loading connection'
- : 'This integration is no longer available.'}
-
- )}
- >
- )}
-
- )
- }
- const query = search.trim().toLowerCase()
- const visible = entries.filter((entry) =>
- [entry.provider, entry.name, ...entry.accounts.map((item) => item.displayName)]
- .join(' ')
- .toLowerCase()
- .includes(query)
- )
- const showSecrets = secrets.data?.source && 'generic secrets'.includes(query)
- return (
-
- {intro}
- {status ?? (
-
- {errors}
- {showSecrets && (
- }
- title='Generic Secrets'
- description={
- secrets.data?.source?.mode === 'organization'
- ? 'Organization managed'
- : 'Personal secrets'
- }
- href={
- secrets.data?.source?.mode === 'member'
- ? serializeIntegrationView(routes.memberSecrets, { q: navigationSearch })
- : href('secrets')
- }
- clickLabel='Open Generic Secrets integration'
- navigable
- />
- )}
- {visible.map((entry) => (
- }
- title={entry.name}
- description={memberIntegrationStatus(entry)}
- href={href(entry.id)}
- clickLabel={`Open ${entry.name} integration`}
- navigable
- />
- ))}
- {!visible.length && !showSecrets && policies.isSuccess && secrets.isSuccess && (
-
- {query ? 'No matching integrations' : 'No integrations available'}
-
- )}
-
- )}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/integrations/loading.tsx b/apps/sim/app/o/[organizationId]/integrations/loading.tsx
new file mode 100644
index 00000000000..2161f26e64d
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/loading.tsx
@@ -0,0 +1,10 @@
+import { IntegrationsRouteFallback } from '@/platform/app/(shell)/integrations/_components/integrations-page/integrations-route'
+import { PageSurface } from '@/platform/design-system/components/surface/surface'
+
+export default function IntegrationsLoading() {
+ return (
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/integrations/page.test.tsx b/apps/sim/app/o/[organizationId]/integrations/page.test.tsx
index 5728acdeee4..e2c9c5bbd23 100644
--- a/apps/sim/app/o/[organizationId]/integrations/page.test.tsx
+++ b/apps/sim/app/o/[organizationId]/integrations/page.test.tsx
@@ -3,9 +3,9 @@ import { nextNavigationMock, nextNavigationMockFns } from '@sim/testing/mocks/ne
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ context: vi.fn() }))
-vi.mock('@/lib/organizations/surface', () => ({ getOrganizationSurfaceContext: mocks.context }))
-vi.mock('@/app/o/[organizationId]/integrations/integrations', () => ({
- OrganizationIntegrations: () => null,
+vi.mock('@/lib/organizations/surface', () => ({ getOrganizationSurfaceGate: mocks.context }))
+vi.mock('@/app/o/[organizationId]/integrations/hydrated-integrations', () => ({
+ HydratedIntegrations: () => null,
}))
vi.mock('next/navigation', () => nextNavigationMock)
@@ -20,7 +20,10 @@ const props = {
}
beforeEach(() => {
- authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
+ authMockFns.mockGetSession.mockResolvedValue({
+ user: { id: 'viewer' },
+ session: { id: 'session' },
+ })
mocks.context.mockResolvedValue({ searchAccess: { memberScoped: true } })
})
diff --git a/apps/sim/app/o/[organizationId]/integrations/page.tsx b/apps/sim/app/o/[organizationId]/integrations/page.tsx
index befc3a35fa5..1209918db7f 100644
--- a/apps/sim/app/o/[organizationId]/integrations/page.tsx
+++ b/apps/sim/app/o/[organizationId]/integrations/page.tsx
@@ -1,18 +1,25 @@
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { slackSearchOnboardingInputSchema } from '@/lib/api/contracts/knowledge/slack'
-import { getSession } from '@/lib/auth'
import {
searchConnectionPath,
searchConnectionTargetSchema,
} from '@/lib/knowledge/search/connection-target'
import { organizationRoutes } from '@/lib/navigation/paths'
-import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors'
import { slackSearchIntegrationsPath } from '@/lib/slack-search/onboarding'
-import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
-import { OrganizationIntegrations } from '@/app/o/[organizationId]/integrations/integrations'
-import { loadIntegrationConnectionParams } from '@/app/o/[organizationId]/integrations/search-params'
+import { requireIntegrationsViewer } from '@/app/o/[organizationId]/integrations/access'
+import { HydratedIntegrations } from '@/app/o/[organizationId]/integrations/hydrated-integrations'
+import {
+ loadIntegrationConnectionParams,
+ loadIntegrationView,
+ serializeIntegrationView,
+} from '@/app/o/[organizationId]/integrations/search-params'
+import {
+ GENERIC_SECRETS_SEGMENT,
+ integrationPath,
+ integrationSegment,
+} from '@/platform/lib/integrations/model'
export const metadata: Metadata = {
title: 'Integrations',
@@ -25,10 +32,15 @@ interface OrganizationIntegrationsPageProps {
slack?: string | string[]
connectionMode?: string | string[]
optionId?: string | string[]
+ savedProviderId?: string | string[]
provider?: string | string[]
connectorType?: string | string[]
connectorId?: string | string[]
credentialId?: string | string[]
+ integration?: string | string[]
+ account?: string | string[]
+ tools?: string | string[]
+ q?: string | string[]
}>
}
@@ -47,11 +59,13 @@ export default async function OrganizationIntegrationsPage({
selection.credentialId ||
selection.connectionMode ||
selection.optionId ||
+ selection.savedProviderId ||
selection.provider
? searchConnectionTargetSchema.safeParse({
type: 'link',
...(selection.connectionMode ? { connectionMode: selection.connectionMode } : {}),
...(selection.optionId ? { optionId: selection.optionId } : {}),
+ ...(selection.savedProviderId ? { savedProviderId: selection.savedProviderId } : {}),
provider: selection.provider || connector?.providerId,
connectorType: selection.connectorType,
...(selection.connectorId ? { connectorId: selection.connectorId } : {}),
@@ -64,26 +78,34 @@ export default async function OrganizationIntegrationsPage({
slack === undefined ? undefined : slackSearchOnboardingInputSchema.safeParse({ token: slack })
if (context && !context.success) notFound()
const slackToken = context?.data?.token
- const session = await getSession()
- if (!session?.user)
+ const viewer = await requireIntegrationsViewer(
+ organizationId,
+ connectionTarget
+ ? searchConnectionPath(organizationId, connectionTarget)
+ : slackToken
+ ? slackSearchIntegrationsPath(organizationId, slackToken)
+ : organizationRoutes(organizationId).integrations
+ )
+ const { userId } = viewer.principal
+ const view = await loadIntegrationView(query)
+ if (view.integration) {
+ const segment =
+ view.integration === 'secrets'
+ ? GENERIC_SECRETS_SEGMENT
+ : integrationSegment(view.integration)
redirect(
- buildAuthCrossLink('/login', {
- callbackUrl: connectionTarget
- ? searchConnectionPath(organizationId, connectionTarget)
- : slackToken
- ? slackSearchIntegrationsPath(organizationId, slackToken)
- : organizationRoutes(organizationId).integrations,
- isInviteFlow: false,
+ serializeIntegrationView(integrationPath(organizationId, segment), {
+ q: view.q,
+ account: view.account,
+ tools: view.tools,
})
)
- const organizationContext = await getOrganizationSurfaceContext(organizationId, session.user.id)
- if (!organizationContext?.searchAccess.memberScoped) notFound()
+ }
return (
-
)
}
diff --git a/apps/sim/app/o/[organizationId]/integrations/search-params.ts b/apps/sim/app/o/[organizationId]/integrations/search-params.ts
index b1d784d7f7d..48d7c602dfc 100644
--- a/apps/sim/app/o/[organizationId]/integrations/search-params.ts
+++ b/apps/sim/app/o/[organizationId]/integrations/search-params.ts
@@ -1,5 +1,14 @@
import { createLoader, createSerializer, parseAsString } from 'nuqs/server'
-import { organizationPageParsers } from '@/app/o/[organizationId]/components/organization-page/search-params'
+
+/** The list's search text, written raw and debounced on the way to the URL. */
+export const integrationSearchParsers = {
+ q: parseAsString.withDefault(''),
+} as const
+
+export const integrationSearchUrlKeys = {
+ history: 'replace',
+ clearOnDefault: true,
+} as const
export const integrationViewParsers = {
integration: parseAsString,
@@ -8,7 +17,13 @@ export const integrationViewParsers = {
} as const
export const serializeIntegrationView = createSerializer({
- ...organizationPageParsers,
+ ...integrationSearchParsers,
+ ...integrationViewParsers,
+})
+
+/** Reads a link from before providers had their own route, so it can be sent there. */
+export const loadIntegrationView = createLoader({
+ ...integrationSearchParsers,
...integrationViewParsers,
})
@@ -16,6 +31,7 @@ export const integrationConnectionParams = {
connectorType: parseAsString.withDefault(''),
connectionMode: parseAsString.withDefault(''),
optionId: parseAsString.withDefault(''),
+ savedProviderId: parseAsString.withDefault(''),
provider: parseAsString.withDefault(''),
connectorId: parseAsString.withDefault(''),
credentialId: parseAsString.withDefault(''),
diff --git a/apps/sim/app/o/[organizationId]/integrations/secrets/loading.tsx b/apps/sim/app/o/[organizationId]/integrations/secrets/loading.tsx
new file mode 100644
index 00000000000..5a0b919aad4
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/integrations/secrets/loading.tsx
@@ -0,0 +1,14 @@
+import { IntegrationsPage } from '@/platform/app/(shell)/integrations/_components/integrations-page/integrations-page'
+import { LoadingRows } from '@/platform/design-system/components'
+import { PageSurface } from '@/platform/design-system/components/surface/surface'
+
+/** Its own, so the secrets page does not inherit the Integrations list's heading while it loads. */
+export default function MemberSecretsLoading() {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/components/secrets/organization-secrets-page.tsx b/apps/sim/app/o/[organizationId]/integrations/secrets/organization-secrets-page.tsx
similarity index 64%
rename from apps/sim/components/secrets/organization-secrets-page.tsx
rename to apps/sim/app/o/[organizationId]/integrations/secrets/organization-secrets-page.tsx
index 336127b5ba7..6c4a6a9631e 100644
--- a/apps/sim/components/secrets/organization-secrets-page.tsx
+++ b/apps/sim/app/o/[organizationId]/integrations/secrets/organization-secrets-page.tsx
@@ -1,14 +1,16 @@
import { Suspense } from 'react'
import { notFound, redirect } from 'next/navigation'
-import { OrganizationSecretsEditor } from '@/components/secrets/organization-secrets-editor'
-import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
import { getSession } from '@/lib/auth'
import { organizationRoutes } from '@/lib/navigation/paths'
import { readOrganizationSecretSource } from '@/lib/organization-secrets/application/use-cases'
import type { SecretSourceMode } from '@/lib/organization-secrets/validation'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
-import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import { IntegrationsPage } from '@/platform/app/(shell)/integrations/_components/integrations-page/integrations-page'
+import { SecretsEditor } from '@/platform/app/(shell)/integrations/_components/secrets-editor/secrets-editor'
+import { SettingsSubpageHost } from '@/platform/app/(shell)/settings/_components/settings-subpage-host'
+import { LoadingRows } from '@/platform/design-system/components'
+import { PageSurface } from '@/platform/design-system/components/surface/surface'
interface OrganizationSecretsPageProps {
organizationId: string
@@ -38,15 +40,16 @@ export async function OrganizationSecretsPage({
})
if (source?.mode !== mode)
redirect(mode === 'organization' ? routes.settingsSection('integrations') : routes.integrations)
- return (
-
-
- Loading secrets…}
- >
-
-
-
-
+ const editor = (
+ }>
+
+
+ )
+ return mode === 'organization' ? (
+ {editor}
+ ) : (
+
+ {editor}
+
)
}
diff --git a/apps/sim/app/o/[organizationId]/integrations/secrets/page.tsx b/apps/sim/app/o/[organizationId]/integrations/secrets/page.tsx
index 083ca5c6423..b66656f87c0 100644
--- a/apps/sim/app/o/[organizationId]/integrations/secrets/page.tsx
+++ b/apps/sim/app/o/[organizationId]/integrations/secrets/page.tsx
@@ -1,9 +1,11 @@
import type { Metadata } from 'next'
import { createLoader, type SearchParams } from 'nuqs/server'
-import { OrganizationSecretsPage } from '@/components/secrets/organization-secrets-page'
import { organizationRoutes } from '@/lib/navigation/paths'
-import { organizationPageParsers } from '@/app/o/[organizationId]/components/organization-page/search-params'
-import { serializeIntegrationView } from '@/app/o/[organizationId]/integrations/search-params'
+import {
+ integrationSearchParsers,
+ serializeIntegrationView,
+} from '@/app/o/[organizationId]/integrations/search-params'
+import { OrganizationSecretsPage } from '@/app/o/[organizationId]/integrations/secrets/organization-secrets-page'
export const metadata: Metadata = { title: 'Generic Secrets' }
@@ -12,7 +14,7 @@ interface MemberSecretsPageProps {
searchParams: Promise
}
-const loadSearch = createLoader(organizationPageParsers)
+const loadSearch = createLoader(integrationSearchParsers)
export default async function Page({ params, searchParams }: MemberSecretsPageProps) {
const { organizationId } = await params
diff --git a/apps/sim/app/o/[organizationId]/integrations/slack-search-actions.tsx b/apps/sim/app/o/[organizationId]/integrations/slack-search-actions.tsx
deleted file mode 100644
index aec4a98815c..00000000000
--- a/apps/sim/app/o/[organizationId]/integrations/slack-search-actions.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-'use client'
-
-import { Chip, ChipLink } from '@sim/emcn'
-import { slackSearchOnboardingPath } from '@/lib/slack-search/onboarding'
-import {
- useRetrySlackSearchOnboarding,
- useSlackSearchOnboarding,
-} from '@/hooks/queries/slack-search-onboarding'
-
-interface SlackSearchActionsProps {
- organizationId: string
- token: string
- userId: string
-}
-
-/** Slack adds only thread return and retry actions to the ordinary integrations page. */
-export function SlackSearchActions({ organizationId, token, userId }: SlackSearchActionsProps) {
- const status = useSlackSearchOnboarding(token, userId)
- const retry = useRetrySlackSearchOnboarding(userId)
- if (status.isPending) return null
- if (status.isError)
- return (
-
- {status.error.message}
-
- )
- const data = status.data
- if (!('organizationId' in data) || data.organizationId !== organizationId)
- return Finish Slack setup
- const retried = data.status === 'retried' || retry.isSuccess
- return (
-
- {!retried &&
- (data.status === 'ready' ? (
-
retry.mutate({ token })}
- >
- {retry.isPending ? 'Queuing' : 'Retry question in Slack'}
-
- ) : (
-
void status.refetch()}>
- {status.isFetching ? 'Checking' : 'Check connection'}
-
- ))}
-
- Return to Slack
-
- {retry.isError && (
-
- {retry.error.message}
-
- )}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/layout.test.tsx b/apps/sim/app/o/[organizationId]/layout.test.tsx
index 1598aedf5b6..eb2239e4e40 100644
--- a/apps/sim/app/o/[organizationId]/layout.test.tsx
+++ b/apps/sim/app/o/[organizationId]/layout.test.tsx
@@ -55,8 +55,8 @@ vi.mock('@/lib/organizations/surface', () => ({
getOrganizationSurfaceContext: mockGetOrganizationSurfaceContext,
}))
-vi.mock('@/app/o/[organizationId]/components/organization-sidebar', () => ({
- OrganizationSidebar: () => null,
+vi.mock('@/platform/app/(shell)/_components/sidebar/sidebar', () => ({
+ Sidebar: () => null,
}))
vi.mock('@/app/workspace/[workspaceId]/components/workspace-chrome', () => ({
diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx
index ddaa31bf583..d7482cb9871 100644
--- a/apps/sim/app/o/[organizationId]/layout.tsx
+++ b/apps/sim/app/o/[organizationId]/layout.tsx
@@ -1,3 +1,4 @@
+import { cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { cookies } from 'next/headers'
@@ -11,6 +12,7 @@ import { isComputerUseAvailable } from '@/lib/computer-use/availability.server'
import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
+import { OrganizationProvider } from '@/lib/organizations/provider'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
import { canBrowsePeople } from '@/lib/people/feature-flag'
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
@@ -20,10 +22,8 @@ import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied'
-import { OrganizationSidebar } from '@/app/o/[organizationId]/components/organization-sidebar'
-import { SimLinkOrganizationProvider } from '@/app/o/[organizationId]/links/sim-link-organization-provider'
+import { OrganizationSimLinkProvider } from '@/app/o/[organizationId]/links/organization-sim-link-provider'
import { prefetchOrganizationSidebar } from '@/app/o/[organizationId]/prefetch'
-import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider'
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired'
import {
@@ -32,6 +32,8 @@ import {
} from '@/app/workspace/[workspaceId]/components/workspace-chrome'
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
+import { Sidebar } from '@/platform/app/(shell)/_components/sidebar/sidebar'
+import hostBridge from '@/platform/design-system/styles/host-bridge.module.css'
const logger = createLogger('OrganizationLayout')
@@ -131,15 +133,18 @@ export default async function OrganizationLayout({
}}
>
-
+
-
+
-
+
diff --git a/apps/sim/app/o/[organizationId]/links/columns.ts b/apps/sim/app/o/[organizationId]/links/columns.ts
deleted file mode 100644
index f522b1d3a5d..00000000000
--- a/apps/sim/app/o/[organizationId]/links/columns.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Globe, Plus } from '@sim/emcn/icons'
-import type { ResourceColumn } from '@/app/workspace/[workspaceId]/components/resource/resource'
-
-/** Shared by the page and its loading chrome so the skeleton matches the table. */
-export const SIM_LINK_COLUMNS = [
- { id: 'name', header: 'Name' },
- { id: 'destination', header: 'Destination', widthMultiplier: 2 },
- { id: 'description', header: 'Description', widthMultiplier: 1.4 },
- { id: 'owner', header: 'Created by' },
- { id: 'access', header: 'Access' },
- { id: 'updated', header: 'Updated' },
-] as const satisfies readonly ResourceColumn[]
-
-export const SIM_LINKS_SEARCH_PLACEHOLDER = 'Search links...'
-
-export const INSTALL_EXTENSION_ACTION = { text: 'Install Sim for Chrome', icon: Globe } as const
-export const NEW_SIM_LINK_ACTION = { text: 'New link', icon: Plus, variant: 'primary' } as const
diff --git a/apps/sim/app/o/[organizationId]/links/loading.tsx b/apps/sim/app/o/[organizationId]/links/loading.tsx
index f955ff8dd99..d95e6b2750c 100644
--- a/apps/sim/app/o/[organizationId]/links/loading.tsx
+++ b/apps/sim/app/o/[organizationId]/links/loading.tsx
@@ -1,27 +1,13 @@
-'use client'
-
-import { Link } from '@sim/emcn/icons'
-import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
-import {
- INSTALL_EXTENSION_ACTION,
- NEW_SIM_LINK_ACTION,
- SIM_LINK_COLUMNS,
- SIM_LINKS_SEARCH_PLACEHOLDER,
-} from '@/app/o/[organizationId]/links/columns'
-import { ResourceChromeFallback } from '@/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback'
+import { LinksFrame } from '@/platform/app/(shell)/links/_components/links-page/links-frame'
+import { LoadingRows } from '@/platform/design-system/components/status/query-boundary'
+import { PageSurface } from '@/platform/design-system/components/surface/surface'
export default function SimLinksLoading() {
- const { hosted } = useDeploymentShape()
return (
-
-
-
+
+
+
+
+
)
}
diff --git a/apps/sim/app/o/[organizationId]/links/organization-sim-link-provider.tsx b/apps/sim/app/o/[organizationId]/links/organization-sim-link-provider.tsx
new file mode 100644
index 00000000000..a926950c66b
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/links/organization-sim-link-provider.tsx
@@ -0,0 +1,37 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import dynamic from 'next/dynamic'
+import { SimLinkOrganizationProvider } from '@/app/o/[organizationId]/links/sim-link-organization-provider'
+import { Surface } from '@/platform/design-system/components/surface/surface'
+
+/** Every organization route mounts this provider; only a new-link shortcut needs the form. */
+const LinkEditor = dynamic(() =>
+ import('@/platform/app/(shell)/links/_components/links-page/link-editor').then(
+ (module) => module.LinkEditor
+ )
+)
+
+interface OrganizationSimLinkProviderProps {
+ organizationId: string | null
+ children: ReactNode
+}
+
+/** The shortcuts' scope on the organization surface, whose new-link form is the Links page's. */
+export function OrganizationSimLinkProvider({
+ organizationId,
+ children,
+}: OrganizationSimLinkProviderProps) {
+ return (
+ (
+
+
+
+ )}
+ >
+ {children}
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/links/page.tsx b/apps/sim/app/o/[organizationId]/links/page.tsx
index 21e80786b66..8d05d8ae737 100644
--- a/apps/sim/app/o/[organizationId]/links/page.tsx
+++ b/apps/sim/app/o/[organizationId]/links/page.tsx
@@ -1,27 +1,34 @@
+import { HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
-import { notFound, redirect } from 'next/navigation'
-import { getSession } from '@/lib/auth'
+import { notFound } from 'next/navigation'
import { organizationRoutes } from '@/lib/navigation/paths'
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
-import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
-import { SimLinks } from '@/app/o/[organizationId]/links/sim-links'
+import { loadSimLinksListView } from '@/lib/sim-links/search-params'
+import { requireOrganizationViewer } from '@/app/o/[organizationId]/viewer'
+import { LinksPage } from '@/platform/app/(shell)/links/_components/links-page/links-page'
+import { PageSurface } from '@/platform/design-system/components/surface/surface'
+import { prefetchSimLinks } from '@/platform/lib/data/prefetch'
export const metadata: Metadata = { title: 'Links' }
interface SimLinksPageProps {
params: Promise<{ organizationId: string }>
+ searchParams: Promise>
}
-export default async function SimLinksPage({ params }: SimLinksPageProps) {
- const { organizationId } = await params
- const session = await getSession()
- if (!session?.user)
- redirect(
- buildAuthCrossLink('/login', {
- callbackUrl: organizationRoutes(organizationId).simLinks,
- isInviteFlow: false,
- })
- )
+export default async function SimLinksPage({ params, searchParams }: SimLinksPageProps) {
+ const [{ organizationId }, query] = await Promise.all([params, searchParams])
+ const { principal } = await requireOrganizationViewer(
+ organizationId,
+ organizationRoutes(organizationId).simLinks
+ )
if (!(await isSimLinksEnabled(organizationId))) notFound()
- return
+ const state = await prefetchSimLinks(organizationId, principal, loadSimLinksListView(query))
+ return (
+
+
+
+
+
+ )
}
diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-context-menu.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-context-menu.tsx
deleted file mode 100644
index 98e08ece0c4..00000000000
--- a/apps/sim/app/o/[organizationId]/links/sim-link-context-menu.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-'use client'
-
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@sim/emcn'
-import { ArrowUpRight, Duplicate, Pencil, Trash } from '@sim/emcn/icons'
-
-interface SimLinkContextMenuProps {
- isOpen: boolean
- position: { x: number; y: number }
- onClose: () => void
- onOpen: () => void
- onCopyLink: () => void
- /** Present only when the viewer may edit the link. */
- onEdit?: () => void
- /** Present only when the viewer may delete the link. */
- onDelete?: () => void
-}
-
-/** Row actions for one Sim link, anchored where the row was right-clicked. */
-export function SimLinkContextMenu({
- isOpen,
- position,
- onClose,
- onOpen,
- onCopyLink,
- onEdit,
- onDelete,
-}: SimLinkContextMenuProps) {
- return (
- !open && onClose()} modal={false}>
-
-
-
- e.preventDefault()}
- >
-
-
- Open
-
-
-
- Copy link
-
- {onEdit && (
-
-
- Edit
-
- )}
- {onDelete && (
- <>
-
-
-
- Delete
-
- >
- )}
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx
deleted file mode 100644
index f28f247f9ce..00000000000
--- a/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Globe } from '@sim/emcn/icons'
-import { Favicon } from '@/components/favicon'
-import { simLinkOrigin } from '@/lib/sim-links/validation'
-
-interface SimLinkIconProps {
- /** The link's destination; a variable link's `{*}` never changes its host. */
- url: string
-}
-
-/** The destination site's favicon, or a globe when neither it nor a parent domain has one. */
-export function SimLinkIcon({ url }: SimLinkIconProps) {
- const origin = simLinkOrigin(url)
- return } />
-}
diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx
index 03d1b30fc83..9092a4a9e6d 100644
--- a/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx
+++ b/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx
@@ -20,12 +20,25 @@ const SimLinkScopeContext = createContext({
openForm: () => {},
})
+interface SimLinkFormProps {
+ organizationId: string
+ initialUrl: string
+ initialName: string
+ onClose: () => void
+}
+
interface SimLinkOrganizationProviderProps {
/** Where `sim/` resolves for this session, or null when it resolves nowhere. */
organizationId: string | null
+ /** The new-link form, for a surface with its own. This module stays free of the design system. */
+ renderForm?: (form: SimLinkFormProps) => ReactNode
children: ReactNode
}
+function renderLegacyForm(form: SimLinkFormProps) {
+ return
+}
+
/**
* Scopes Sim link shortcuts to the organization whose links `sim/` opens for the viewer, and hosts
* the new-link form at the layout root. A form opened from a menu row inside a popover would
@@ -34,6 +47,7 @@ interface SimLinkOrganizationProviderProps {
*/
export function SimLinkOrganizationProvider({
organizationId,
+ renderForm = renderLegacyForm,
children,
}: SimLinkOrganizationProviderProps) {
const [draft, setDraft] = useState(null)
@@ -42,14 +56,14 @@ export function SimLinkOrganizationProvider({
value={{ organizationId, openForm: (url, title) => setDraft({ url, title }) }}
>
{children}
- {draft && organizationId && (
- setDraft(null)}
- />
- )}
+ {draft &&
+ organizationId &&
+ renderForm({
+ organizationId,
+ initialUrl: draft.url,
+ initialName: suggestSimLinkName(draft.title),
+ onClose: () => setDraft(null),
+ })}
)
}
diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx
index e47e9588ec4..522ea8c413d 100644
--- a/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx
+++ b/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx
@@ -40,8 +40,8 @@ function useNestedSimLinkCreator(url: string, title: string): SimLinkCreator {
}
/** The first link that already opens `url`, and a copy of its address. */
-function useExistingSimLink(organizationId: string, url: string) {
- const { data, isPending, isError } = useSimLinksForUrl(organizationId, url)
+export function useExistingSimLink(organizationId: string, url: string, enabled = true) {
+ const { data, isPending, isError } = useSimLinksForUrl(organizationId, url, enabled)
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
const existing = data?.at(0)
const copyExisting = async () => {
diff --git a/apps/sim/app/o/[organizationId]/links/sim-links.tsx b/apps/sim/app/o/[organizationId]/links/sim-links.tsx
deleted file mode 100644
index d966744ebb8..00000000000
--- a/apps/sim/app/o/[organizationId]/links/sim-links.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-'use client'
-
-import { type MouseEvent, useCallback, useMemo, useState } from 'react'
-import { Avatar, ChipConfirmModal, ChipModalError, toast, writeTextToClipboard } from '@sim/emcn'
-import { Link } from '@sim/emcn/icons'
-import { createLogger } from '@sim/logger'
-import { useQueryStates } from 'nuqs'
-import type { SimLink } from '@/lib/api/contracts/sim-links'
-import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
-import { simLinkPath, simLinkUrl } from '@/lib/navigation/paths'
-import { SIM_LINK_SEARCH_MAX_LENGTH, toSimLinkName } from '@/lib/sim-links/validation'
-import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
-import {
- INSTALL_EXTENSION_ACTION,
- NEW_SIM_LINK_ACTION,
- SIM_LINK_COLUMNS,
- SIM_LINKS_SEARCH_PLACEHOLDER,
-} from '@/app/o/[organizationId]/links/columns'
-import {
- simLinksParsers,
- simLinksSortParams,
- simLinksUrlKeys,
-} from '@/app/o/[organizationId]/links/search-params'
-import { SimLinkContextMenu } from '@/app/o/[organizationId]/links/sim-link-context-menu'
-import { SimLinkIcon } from '@/app/o/[organizationId]/links/sim-link-icon'
-import { SimLinkModal } from '@/app/o/[organizationId]/links/sim-link-modal'
-import {
- ResourceLoadError,
- ResourceNoResults,
- SimLinksEmptyState,
-} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
-import type {
- SearchConfig,
- SortConfig,
-} from '@/app/workspace/[workspaceId]/components/resource/components/resource-options'
-import { timeCell } from '@/app/workspace/[workspaceId]/components/resource/components/time-cell'
-import {
- Resource,
- type ResourceRow,
-} from '@/app/workspace/[workspaceId]/components/resource/resource'
-import { useDeleteSimLink, useSimLinks } from '@/hooks/queries/sim-links'
-import { useContextMenu } from '@/hooks/use-context-menu'
-import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
-import { useSearchFilterValue } from '@/hooks/use-search-filter-value'
-import { useUrlSort } from '@/hooks/use-url-sort'
-
-const logger = createLogger('SimLinks')
-
-const SIM_FOR_CHROME_URL =
- 'https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg'
-
-const SORT_OPTIONS = [
- { id: 'name', label: 'Name' },
- { id: 'popular', label: 'Opens' },
-] as const
-
-/** How the link is shared beyond the default: open to everyone and editable by any member. */
-function accessLabel(link: SimLink): string | null {
- const limits = [link.unlisted && 'Unlisted', link.restrictEdit && 'Restricted'].filter(Boolean)
- return limits.length ? limits.join(', ') : null
-}
-
-function openLink(name: string) {
- window.open(simLinkPath(name), '_blank', 'noopener,noreferrer')
-}
-
-async function copyLink(name: string) {
- try {
- await writeTextToClipboard(simLinkUrl(window.location.origin, name))
- toast.success('Link copied')
- } catch (error) {
- logger.error('Failed to copy Sim link', { error })
- toast.error('Unable to copy link')
- }
-}
-
-interface SimLinksProps {
- organizationId: string
-}
-
-/** Every Sim link the viewer may browse, in the shared resource table. */
-export function SimLinks({ organizationId }: SimLinksProps) {
- const { hosted } = useDeploymentShape()
- const [{ search, create }, setView] = useQueryStates(simLinksParsers, simLinksUrlKeys)
- const setSearch = useDebouncedSearchSetter((value, options) =>
- setView({ search: value }, options)
- )
- const debouncedSearch = useSearchFilterValue(search, SEARCH_DEBOUNCE_MS)
- const { sort, dir, activeSort, onSort, onClear } = useUrlSort(simLinksSortParams, simLinksUrlKeys)
- const query = debouncedSearch.trim()
- const links = useSimLinks(organizationId, query, sort, dir)
- const remove = useDeleteSimLink(organizationId)
- const contextMenu = useContextMenu()
- const [menuLinkId, setMenuLinkId] = useState(null)
- const [editing, setEditing] = useState(null)
- const [deleting, setDeleting] = useState(null)
-
- const loaded = useMemo(() => links.data?.pages.flatMap((page) => page.links) ?? [], [links.data])
- const linksById = useMemo(() => new Map(loaded.map((link) => [link.id, link])), [loaded])
- const menuLink = menuLinkId ? linksById.get(menuLinkId) : undefined
- // A row can leave the results while its menu is open; forgetting it keeps the menu from
- // reappearing at the old position when the row comes back.
- if (menuLinkId && !menuLink) setMenuLinkId(null)
-
- const rows: ResourceRow[] = useMemo(
- () =>
- loaded.map((link) => ({
- id: link.id,
- cells: {
- name: { icon: , label: link.name },
- destination: { label: link.url },
- description: { label: link.description },
- owner: link.createdBy
- ? {
- icon: (
-
- ),
- label: link.createdBy.name,
- }
- : { label: null },
- access: { label: accessLabel(link) },
- updated: timeCell(link.updatedAt),
- },
- })),
- [loaded]
- )
-
- const searchConfig: SearchConfig = {
- value: search,
- maxLength: SIM_LINK_SEARCH_MAX_LENGTH,
- onChange: setSearch,
- onClearAll: () => setSearch(''),
- placeholder: SIM_LINKS_SEARCH_PLACEHOLDER,
- }
- const sortConfig: SortConfig = { options: SORT_OPTIONS, active: activeSort, onSort, onClear }
- const startCreate = () => void setView({ create: '' })
- const { fetchNextPage } = links
- const { handleContextMenu } = contextMenu
- // Rows are memoized, so stable handlers keep a keystroke from re-rendering every row.
- const openRow = useCallback(
- (id: string) => {
- const link = linksById.get(id)
- if (link) openLink(link.name)
- },
- [linksById]
- )
- const openRowMenu = useCallback(
- (event: MouseEvent, id: string) => {
- if (!linksById.has(id)) return
- setMenuLinkId(id)
- handleContextMenu(event)
- },
- [linksById, handleContextMenu]
- )
- const loadMore = useCallback(() => void fetchNextPage(), [fetchNextPage])
-
- const showEmptyState = Boolean(links.data || links.isLoadingError)
-
- return (
-
- window.open(SIM_FOR_CHROME_URL, '_blank', 'noopener,noreferrer'),
- },
- ]
- : []),
- { ...NEW_SIM_LINK_ACTION, id: 'create', onSelect: startCreate },
- ]}
- />
-
- void links.refetch()} />
- ) : query ? (
- setSearch('')}
- />
- ) : (
-
- ))
- }
- />
- {menuLink && (
- openLink(menuLink.name)}
- onCopyLink={() => void copyLink(menuLink.name)}
- onEdit={menuLink.canEdit ? () => setEditing(menuLink) : undefined}
- onDelete={
- menuLink.canEdit
- ? () => {
- remove.reset()
- setDeleting(menuLink)
- }
- : undefined
- }
- />
- )}
- {create !== null && (
- void setView({ create: null })}
- />
- )}
- {editing && (
- setEditing(null)}
- />
- )}
- {
- if (!open && !remove.isPending) setDeleting(null)
- }}
- title='Delete link'
- text={`${deleting?.name ?? 'This link'} will stop working for everyone in your organization.`}
- confirm={{
- label: 'Delete',
- pendingLabel: 'Deleting',
- pending: remove.isPending,
- disabled: remove.isPending,
- onClick: () => {
- if (deleting) remove.mutate(deleting.id, { onSuccess: () => setDeleting(null) })
- },
- }}
- >
- {remove.error?.message}
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/not-found.tsx b/apps/sim/app/o/[organizationId]/not-found.tsx
index fc15b03c1cd..3b44d8c0c4c 100644
--- a/apps/sim/app/o/[organizationId]/not-found.tsx
+++ b/apps/sim/app/o/[organizationId]/not-found.tsx
@@ -1,10 +1,10 @@
'use client'
-import { Chip, ChipLink } from '@sim/emcn'
-import { ArrowLeft, Compass, Home } from '@sim/emcn/icons'
+import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import { organizationRoutes } from '@/lib/navigation/paths'
-import { ErrorShell } from '@/app/workspace/[workspaceId]/components/error/error'
+import { RouteStatus } from '@/platform/app/(shell)/_components/route-status/route-status'
+import { Button } from '@/platform/design-system/components/button/button'
export default function OrganizationNotFound() {
const router = useRouter()
@@ -12,17 +12,19 @@ export default function OrganizationNotFound() {
const homeHref = organizationId ? organizationRoutes(organizationId).home : '/o'
return (
- }
- >
- router.back()}>
- Go back
-
-
- Return home
-
-
+ actions={
+ <>
+ router.back()}>
+ Go back
+
+ }>
+ Return home
+
+ >
+ }
+ />
)
}
diff --git a/apps/sim/app/o/[organizationId]/page.tsx b/apps/sim/app/o/[organizationId]/page.tsx
index 82b486cc5cf..044674e5b29 100644
--- a/apps/sim/app/o/[organizationId]/page.tsx
+++ b/apps/sim/app/o/[organizationId]/page.tsx
@@ -1,7 +1,8 @@
import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
-import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
+import { getOrganizationHomeRedirect } from '@/lib/organizations/home-redirect'
+import { getOrganizationSurfaceGate } from '@/lib/organizations/surface'
import { canBrowsePeople } from '@/lib/people/feature-flag'
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
@@ -17,9 +18,9 @@ export default async function OrganizationPage({
if (!session?.user?.id) {
redirect(buildAuthCrossLink('/login', { callbackUrl: routes.root, isInviteFlow: false }))
}
- const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
+ const context = await getOrganizationSurfaceGate(organizationId, session.user.id)
if (!context) notFound()
- if (context.mothershipAvailable && context.canBuild) redirect(routes.home)
+ if (!getOrganizationHomeRedirect(context, organizationId)) redirect(routes.home)
if (context.searchAccess.memberScoped) redirect(routes.search)
if (await canBrowsePeople(organizationId, context.viewer.role)) redirect(routes.people)
redirect((await isSimLinksEnabled(organizationId)) ? routes.simLinks : WORKSPACE_SETTINGS_PATH)
diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx
index 4d1e787708c..60656268999 100644
--- a/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx
+++ b/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx
@@ -1,5 +1,10 @@
-import { ApplicationLoading } from '@/app/_shell/application-loading'
+import { PersonSkeleton } from '@/platform/app/(shell)/people/_components/person/person-skeleton'
+import { PageSurface } from '@/platform/design-system/components/surface/surface'
-export default function Loading() {
- return
+export default function PersonLoading() {
+ return (
+
+
+
+ )
}
diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx
index 5c3144801f6..a9039eeb661 100644
--- a/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx
+++ b/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx
@@ -1,12 +1,12 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
-import { notFound, redirect } from 'next/navigation'
-import { getSession } from '@/lib/auth'
+import { notFound } from 'next/navigation'
import { organizationRoutes } from '@/lib/navigation/paths'
-import { isPeopleEnabled } from '@/lib/people/feature-flag'
-import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
-import PageLoading from '@/app/o/[organizationId]/people/[memberId]/loading'
-import { Person } from '@/app/o/[organizationId]/people/[memberId]/person'
+import { canBrowsePeople } from '@/lib/people/feature-flag'
+import { requireOrganizationViewer } from '@/app/o/[organizationId]/viewer'
+import { PersonProfile } from '@/platform/app/(shell)/people/_components/person/person'
+import { PersonSkeleton } from '@/platform/app/(shell)/people/_components/person/person-skeleton'
+import { PageSurface } from '@/platform/design-system/components/surface/surface'
export const metadata: Metadata = { title: 'People' }
@@ -16,18 +16,16 @@ interface PersonPageProps {
export default async function PersonPage({ params }: PersonPageProps) {
const { organizationId, memberId } = await params
- const session = await getSession()
- if (!session?.user)
- redirect(
- buildAuthCrossLink('/login', {
- callbackUrl: organizationRoutes(organizationId).person(memberId),
- isInviteFlow: false,
- })
- )
- if (!(await isPeopleEnabled(organizationId))) notFound()
+ const { surface } = await requireOrganizationViewer(
+ organizationId,
+ organizationRoutes(organizationId).person(memberId)
+ )
+ if (!(await canBrowsePeople(organizationId, surface.viewer.role))) notFound()
return (
- }>
-
-
+
+ }>
+
+
+
)
}
diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx
deleted file mode 100644
index 8783105b517..00000000000
--- a/apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import {
- Chip,
- ChipCombobox,
- ChipDatePicker,
- ChipModal,
- ChipModalBody,
- ChipModalError,
- ChipModalField,
- ChipModalFooter,
- ChipModalHeader,
-} from '@sim/emcn'
-import type { Person, PersonBody } from '@/lib/api/contracts/people'
-import { memberProfileFieldsSchema, PROFILE_TEXT_MAX_LENGTH } from '@/lib/people/validation'
-import { usePeoplePicker } from '@/app/o/[organizationId]/people/use-people-picker'
-import { useUpdatePerson } from '@/hooks/queries/people'
-
-type TextField = 'title' | 'department' | 'location'
-
-/** The picker's value for no manager; membership ids are never this. */
-const NO_MANAGER = 'no-manager'
-
-const TEXT_FIELDS: readonly { field: TextField; title: string; placeholder: string }[] = [
- { field: 'title', title: 'Title', placeholder: 'Staff Engineer' },
- { field: 'department', title: 'Department', placeholder: 'Engineering' },
- { field: 'location', title: 'Location', placeholder: 'San Francisco' },
-]
-
-interface PersonModalProps {
- organizationId: string
- person: Person
- onClose: () => void
-}
-
-/** Edits one person's profile; managers are chosen from the organization's people. */
-export function PersonModal({ organizationId, person, onClose }: PersonModalProps) {
- const update = useUpdatePerson(organizationId)
- const [draft, setDraft] = useState({
- title: person.title,
- department: person.department,
- location: person.location,
- startDate: person.startDate,
- managerMemberId: person.managerMemberId,
- })
- const [errors, setErrors] = useState>>({})
- /**
- * The chosen manager's name, kept with the choice: the search results may no longer include them.
- * Only a stored manager who is suspended has an id and no name.
- */
- const [managerName, setManagerName] = useState(person.manager?.name ?? null)
- const { people, pickerProps } = usePeoplePicker(organizationId, 'members', person.memberId)
- const managerLabel = draft.managerMemberId ? (managerName ?? 'Suspended member') : 'No manager'
-
- const change = (field: K, value: PersonBody[K]) => {
- setDraft((current) => ({ ...current, [field]: value }))
- setErrors((current) => ({ ...current, [field]: undefined }))
- if (update.isError) update.reset()
- }
-
- const save = () => {
- const parsed = memberProfileFieldsSchema.safeParse(draft)
- if (!parsed.success) {
- setErrors(
- Object.fromEntries(
- parsed.error.issues.map((issue) => [String(issue.path[0]), issue.message])
- )
- )
- return
- }
- update.mutate({ memberId: person.memberId, body: parsed.data }, { onSuccess: onClose })
- }
-
- const title = `Edit ${person.name}`
- return (
- !open && onClose()}
- dismissDisabled={update.isPending}
- srTitle={title}
- >
- {title}
-
- {TEXT_FIELDS.map(({ field, title: label, placeholder }) => (
- change(field, value || null)}
- error={errors[field]}
- />
- ))}
-
- {() => (
-
- change('startDate', value)}
- placeholder='Not recorded'
- disabled={update.isPending}
- fullWidth
- />
- {draft.startDate && (
- change('startDate', null)}>
- Clear
-
- )}
-
- )}
-
-
- {() => (
- {
- const manager = people.find((candidate) => candidate.memberId === value)
- setManagerName(manager?.name ?? null)
- change('managerMemberId', manager?.memberId ?? null)
- }}
- overlayContent={managerLabel}
- searchPlaceholder='Search people...'
- emptyMessage='No one matches'
- disabled={update.isPending}
- className='w-full'
- />
- )}
-
- {update.error?.message}
-
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx
deleted file mode 100644
index c4f9f04103e..00000000000
--- a/apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx
+++ /dev/null
@@ -1,233 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { Avatar, Chip, ChipLink } from '@sim/emcn'
-import { ArrowLeft, Workflow } from '@sim/emcn/icons'
-import Link from 'next/link'
-import { useQueryStates } from 'nuqs'
-import { isApiClientError } from '@/lib/api/client/errors'
-import type { PersonSummary } from '@/lib/api/contracts/people'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { PersonModal } from '@/app/o/[organizationId]/people/[memberId]/person-modal'
-import { peopleParsers, serializePeopleView } from '@/app/o/[organizationId]/people/search-params'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import {
- CredentialDetailHeading,
- CredentialDetailLayout,
- DetailSection,
-} from '@/app/workspace/[workspaceId]/components/credential-detail'
-import {
- SettingsEmptyState,
- SettingsQueryErrorState,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field'
-import {
- RESOURCE_LIST_STACK,
- SettingsResourceRow,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import { usePerson } from '@/hooks/queries/people'
-
-const SOURCE_NAMES = {
- scim: 'SCIM provisioning',
- okta: 'Okta',
- entra: 'Microsoft Entra ID',
-} as const
-const NOT_RECORDED = Not recorded
-
-const START_DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
- dateStyle: 'medium',
- timeZone: 'UTC',
-})
-
-/** `2024-01-31` as a calendar date, with how long ago it was: "Jan 31, 2024 · 2 yrs". */
-function describeStartDate(startDate: string): string {
- const start = new Date(`${startDate}T00:00:00Z`)
- const now = new Date()
- const months =
- (now.getUTCFullYear() - start.getUTCFullYear()) * 12 +
- now.getUTCMonth() -
- start.getUTCMonth() -
- (now.getUTCDate() < start.getUTCDate() ? 1 : 0)
- const tenure =
- months < 0
- ? 'starts soon'
- : months < 1
- ? 'new'
- : months < 12
- ? `${months} mo`
- : `${Math.floor(months / 12)} yr${months >= 24 ? 's' : ''}`
- return `${START_DATE_FORMAT.format(start)} · ${tenure}`
-}
-
-interface PersonRowsProps {
- organizationId: string
- originQuery: string
- people: readonly PersonSummary[]
- empty: string
-}
-
-function PersonRows({ organizationId, originQuery, people, empty }: PersonRowsProps) {
- if (!people.length) return {empty}
- const routes = organizationRoutes(organizationId)
- return (
-
- {people.map((person) => (
- }
- title={person.name}
- description={person.title ?? person.email}
- href={`${routes.person(person.memberId)}${originQuery}`}
- clickLabel={`Open ${person.name}`}
- navigable
- />
- ))}
-
- )
-}
-
-interface PersonProps {
- organizationId: string
- memberId: string
-}
-
-/** One person's profile: what they do, where they are, and where they sit in the organization. */
-export function Person({ organizationId, memberId }: PersonProps) {
- const { viewer } = useOrganizationContext()
- const [origin] = useQueryStates(peopleParsers)
- const query = usePerson(organizationId, memberId)
- const [editing, setEditing] = useState(false)
- const routes = organizationRoutes(organizationId)
- const originQuery = serializePeopleView({ ...origin, view: origin.view ?? 'directory' })
- const notFound = isApiClientError(query.error) && query.error.status === 404
- const refused = isApiClientError(query.error) && query.error.status < 500
- // A transient refresh failure keeps the loaded profile and any edit open on it; a refusal does not.
- const loadError = query.data && !refused ? null : query.error
- const data = loadError ? undefined : query.data
-
- const chartHref = serializePeopleView(routes.people, {
- ...origin,
- view: 'chart',
- focus: data?.person.memberId ?? memberId,
- page: origin.focus === data?.person.memberId ? origin.page : 1,
- panel: null,
- })
- return (
-
- People
-
- }
- actions={
- data ? (
- <>
-
- View in org chart
-
- {viewer.isAdmin && !data.person.directorySource && (
- setEditing(true)}>Edit
- )}
- >
- ) : undefined
- }
- >
- {loadError ? (
- notFound ? (
-
- This person isn't in your organization.
-
- ) : (
- void query.refetch()}
- variant='inline'
- />
- )
- ) : !data ? (
- Loading…
- ) : (
- <>
-
- }
- title={data.person.name}
- subtitle={data.person.title}
- />
-
-
-
-
- {data.person.email}
-
-
-
- {data.person.department ? (
-
- {data.person.department}
-
- ) : (
- NOT_RECORDED
- )}
-
- {data.person.directorySource && (
-
- {SOURCE_NAMES[data.person.directorySource]}
-
- )}
-
{data.person.location ?? NOT_RECORDED}
-
- {data.person.startDate ? describeStartDate(data.person.startDate) : NOT_RECORDED}
-
-
-
-
-
-
-
-
- {data.totalReports > data.reports.length && (
- Browse all {data.totalReports} reports
- )}
-
- >
- )}
- {editing && data && !data.person.directorySource && (
- setEditing(false)}
- />
- )}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/people/chart-canvas.tsx b/apps/sim/app/o/[organizationId]/people/chart-canvas.tsx
deleted file mode 100644
index f7ae966a52d..00000000000
--- a/apps/sim/app/o/[organizationId]/people/chart-canvas.tsx
+++ /dev/null
@@ -1,299 +0,0 @@
-'use client'
-
-import { type MouseEvent, useMemo } from 'react'
-import { Avatar, cn, OverflowText, Tooltip } from '@sim/emcn'
-import { ArrowUpRight, ChevronRight, Minus, Plus } from '@sim/emcn/icons'
-import {
- BLOCK_DIMENSIONS,
- CONNECTION_KNOB_PEAK_PX,
- useCanvasColorMode,
- WorkflowBlockBorder,
- type WorkflowBorderPort,
-} from '@sim/workflow-renderer'
-import {
- BaseEdge,
- type Edge,
- type EdgeProps,
- Handle,
- type Node,
- type NodeProps,
- Position,
- ReactFlow,
- useReactFlow,
- useStore,
-} from '@xyflow/react'
-import Link from 'next/link'
-import { CanvasToolbar, CanvasToolbarButton } from '@/components/canvas-toolbar'
-import type { ChartBranch, PersonSummary } from '@/lib/api/contracts/people'
-import { organizationRoutes } from '@/lib/navigation/paths'
-
-import '@xyflow/react/dist/style.css'
-
-type CardData = {
- person: PersonSummary
- relation: 'Manager' | 'In focus' | 'Direct report'
- department?: string | null
- reportCount?: number
- ports: WorkflowBorderPort[]
- profileUrl: string
- onFocus: (id: string, event: MouseEvent) => void
-}
-type CardNode = Node
-const PORT_POSITIONS = {
- top: Position.Top,
- bottom: Position.Bottom,
- left: Position.Left,
- right: Position.Right,
-} as const
-const WIDTH = BLOCK_DIMENSIONS.FIXED_WIDTH
-const HEIGHT = BLOCK_DIMENSIONS.MIN_HEIGHT
-const LEVEL = HEIGHT + 48
-const COLUMN = WIDTH / 2 + 32
-
-function PersonCard({ data }: NodeProps) {
- const { person, relation, reportCount, department, onFocus, profileUrl, ports } = data
- const focused = relation === 'In focus'
- const contents = (
- <>
-
-
-
-
-
-
-
-
-
-
-
- {focused ? (
- <>
- View profile
-
- >
- ) : (
- <>
- {reportCount === undefined
- ? 'Explore team'
- : `${reportCount} report${reportCount === 1 ? '' : 's'}`}
-
- >
- )}
-
-
- >
- )
- const actionClass =
- 'nodrag nopan pointer-events-auto relative flex size-full cursor-pointer flex-col rounded-2xl bg-[var(--person-card-fill)] text-left outline-offset-2 [--person-card-fill:var(--surface-2)] hover-hover:[--person-card-fill:var(--surface-1)] focus-visible:outline-2 focus-visible:outline-[var(--brand-secondary)] [&_[data-workflow-block-border]_path]:pointer-events-none'
- return (
- <>
- {ports.map((port) => (
-
- ))}
- {focused ? (
-
- {contents}
-
- ) : (
- <>
- onFocus(person.memberId, event)}
- aria-label={`Explore ${person.name}`}
- >
- {contents}
-
-
-
-
-
-
-
- View profile
-
- >
- )}
- >
- )
-}
-
-function ReportingLine({ sourceX, sourceY, targetX, targetY }: EdgeProps) {
- const direction = Math.sign(targetX - sourceX)
- const radius = Math.min(9, Math.abs(targetX - sourceX), Math.abs(targetY - sourceY))
- const path =
- direction === 0
- ? `M ${sourceX} ${sourceY} V ${targetY}`
- : `M ${sourceX} ${sourceY} V ${targetY - radius} Q ${sourceX} ${targetY} ${sourceX + direction * radius} ${targetY} H ${targetX}`
- return (
-
- )
-}
-
-const NODE_TYPES = { person: PersonCard }
-const EDGE_TYPES = { reporting: ReportingLine }
-const MIN_ZOOM = 0.35
-const MAX_ZOOM = 1.6
-const FIT = { padding: 0.22, minZoom: 0.45, maxZoom: 1 }
-
-function ChartControls() {
- const { zoomIn, zoomOut, fitView } = useReactFlow()
- const zoom = useStore((state) => state.transform[2])
- return (
- void fitView(FIT)}>
- void zoomOut()}
- />
- = MAX_ZOOM}
- onClick={() => void zoomIn()}
- />
-
- )
-}
-
-interface ChartCanvasProps {
- profileQuery: string
- organizationId: string
- branch: ChartBranch
- onFocus: CardData['onFocus']
-}
-
-export function ChartCanvas({ organizationId, branch, onFocus, profileQuery }: ChartCanvasProps) {
- const colorMode = useCanvasColorMode()
- const { person, reports, chain } = branch
- const { nodes, edges } = useMemo(() => {
- const routes = organizationRoutes(organizationId)
- const nodes: CardNode[] = []
- const edges: Edge[] = []
- function addCard(
- member: PersonSummary,
- relation: CardData['relation'],
- x: number,
- y: number,
- sides: Position[],
- reportCount?: number,
- department?: string | null
- ) {
- nodes.push({
- id: member.memberId,
- type: 'person',
- position: { x: x - WIDTH / 2, y },
- width: WIDTH,
- height: HEIGHT,
- data: {
- person: member,
- relation,
- ports: sides.map((side) => ({
- id: side,
- side,
- position: 'center',
- plateau: 14,
- hoverAmplitude: 1,
- magnetizable: false,
- })),
- profileUrl: `${routes.person(member.memberId)}${profileQuery}`,
- reportCount,
- department,
- onFocus,
- },
- })
- }
- function connect(source: string, target: string) {
- edges.push({ id: `${source}/${target}`, source, target, type: 'reporting' })
- }
- const manager = chain[0]
- if (manager) {
- addCard(manager, 'Manager', 0, -LEVEL, [Position.Bottom])
- connect(manager.memberId, person.memberId)
- }
- const focusPorts = [
- ...(manager ? [Position.Top] : []),
- ...(reports.length ? [Position.Bottom] : []),
- ]
- addCard(person, 'In focus', 0, 0, focusPorts, undefined, person.department)
- reports.forEach((report, i) => {
- const single = reports.length === 1
- const left = i % 2 === 0
- addCard(
- report,
- 'Direct report',
- single ? 0 : left ? -COLUMN : COLUMN,
- LEVEL + Math.floor(i / 2) * (HEIGHT + 24),
- [single ? Position.Top : left ? Position.Right : Position.Left],
- report.reportCount
- )
- connect(person.memberId, report.memberId)
- })
- return { nodes, edges }
- }, [organizationId, person, chain, reports, profileQuery, onFocus])
-
- return (
-
-
-
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/people/chart-empty-state.tsx b/apps/sim/app/o/[organizationId]/people/chart-empty-state.tsx
deleted file mode 100644
index 19f60470747..00000000000
--- a/apps/sim/app/o/[organizationId]/people/chart-empty-state.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import type { MouseEvent, RefCallback } from 'react'
-import { Chip, ChipLink } from '@sim/emcn'
-import { ArrowUpRight, ChevronRight, RefreshCw, Upload } from '@sim/emcn/icons'
-import { EmptyState } from '@/components/empty-state/empty-state'
-import type { PersonSummary } from '@/lib/api/contracts/people'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline'
-import { usePeople } from '@/hooks/queries/people'
-
-function ReportingLinesGraphic() {
- return (
-
-
- {[
- [72, 1],
- [5, 75],
- [139, 75],
- ].map(([x, y]) => (
-
-
-
-
-
-
- ))}
-
- )
-}
-
-interface ChartEmptyStateProps {
- profileQuery: string
- organizationId: string
- person: PersonSummary
- onFocus: (memberId: string, event: MouseEvent) => void
- currentPersonRef: RefCallback
- onImport?: () => void
- onSync?: () => void
-}
-
-export function ChartEmptyState({
- organizationId,
- profileQuery,
- person,
- onFocus,
- currentPersonRef,
- onImport,
- onSync,
-}: ChartEmptyStateProps) {
- const roots = usePeople(organizationId, '', '', { scope: 'roots' })
- const firstTeam = roots.isSuccess ? roots.data.pages[0]?.people[0] : undefined
- const emptyOrganization = roots.isSuccess && !firstTeam
- const routes = organizationRoutes(organizationId)
-
- return (
- }
- title={emptyOrganization ? 'No reporting lines yet' : `No reporting lines for ${person.name}`}
- description={
- emptyOrganization
- ? onImport
- ? 'Connect a directory, import people data, or add a manager to a profile to build your org chart.'
- : 'Teams appear here when an administrator adds managers to people’s profiles.'
- : 'This profile has no visible manager or direct reports.'
- }
- action={
-
- {firstTeam ? (
- onFocus(firstTeam.memberId, event)}
- >
- Explore a team
-
- ) : emptyOrganization && onSync ? (
-
- Manage directory sync
-
- ) : null}
- {emptyOrganization && onImport && (
-
- Import people
-
- )}
-
- View profile
-
-
- }
- />
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/people/columns.ts b/apps/sim/app/o/[organizationId]/people/columns.ts
deleted file mode 100644
index 9a7a45f83fd..00000000000
--- a/apps/sim/app/o/[organizationId]/people/columns.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { Upload } from '@sim/emcn/icons'
-import type { ResourceColumn } from '@/app/workspace/[workspaceId]/components/resource/resource'
-
-/** Shared by the page and its loading chrome so the skeleton matches the table. */
-export const PEOPLE_COLUMNS = [
- { id: 'name', header: 'Name', widthMultiplier: 1.4 },
- { id: 'title', header: 'Title', widthMultiplier: 1.4 },
- { id: 'department', header: 'Department' },
- { id: 'location', header: 'Location' },
- { id: 'manager', header: 'Manager', widthMultiplier: 1.2 },
-] as const satisfies readonly ResourceColumn[]
-
-export const PEOPLE_SEARCH_PLACEHOLDER = 'Search people...'
-
-export const IMPORT_PEOPLE_ACTION = { text: 'Import', icon: Upload } as const
diff --git a/apps/sim/app/o/[organizationId]/people/directory-sync-form.tsx b/apps/sim/app/o/[organizationId]/people/directory-sync-form.tsx
deleted file mode 100644
index 9d68645cd42..00000000000
--- a/apps/sim/app/o/[organizationId]/people/directory-sync-form.tsx
+++ /dev/null
@@ -1,223 +0,0 @@
-import { useState } from 'react'
-import {
- Chip,
- ChipLink,
- ChipModal,
- ChipModalBody,
- ChipModalDescription,
- ChipModalError,
- ChipModalField,
- ChipModalFooter,
- ChipModalHeader,
-} from '@sim/emcn'
-import { isOrganizationSettingsSectionAvailable } from '@/components/settings/navigation'
-import {
- type PeopleSyncConfig,
- type PeopleSyncStatus,
- peopleSyncConfigSchema,
-} from '@/lib/api/contracts/people-sync'
-import { flattenFieldErrors } from '@/lib/api/contracts/primitives'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { useScimConnection } from '@/ee/scim/hooks/scim'
-import { serializeSsoSettings } from '@/ee/sso/search-params'
-import type { usePeopleSyncActions } from '@/hooks/queries/people-sync'
-
-interface DirectorySyncFormProps {
- organizationId: string
- source: PeopleSyncConfig['source']
- name: string
- current: PeopleSyncStatus | null
- actions: ReturnType
- onBack: () => void
- onClose: () => void
-}
-
-export function DirectorySyncForm({
- organizationId,
- source,
- name,
- current,
- actions,
- onBack,
- onClose,
-}: DirectorySyncFormProps) {
- const [domain, setDomain] = useState(current?.domain ?? '')
- const [tenantId, setTenantId] = useState(current?.tenantId ?? '')
- const [clientId, setClientId] = useState(current?.clientId ?? '')
- const [secret, setSecret] = useState('')
- const [fieldErrors, setFieldErrors] = useState<
- Partial>
- >({})
- const { settingsFeatures } = useOrganizationContext()
- const provisioning = useScimConnection(organizationId, source === 'scim')
- const scimReady = provisioning.isSuccess && provisioning.data.connection?.status === 'active'
- const title = current ? `Update ${name} credentials` : `Connect ${name}`
- const disabled = actions.pending
- const canSubmit =
- source === 'scim'
- ? scimReady
- : secret.trim() && (source === 'okta' ? domain.trim() : tenantId.trim() && clientId.trim())
-
- function connect() {
- if (disabled) return
- const config = peopleSyncConfigSchema.safeParse(
- source === 'okta'
- ? { source, domain, token: secret }
- : source === 'entra'
- ? { source, tenantId: tenantId.trim(), clientId: clientId.trim(), clientSecret: secret }
- : { source }
- )
- if (!config.success) {
- setFieldErrors(flattenFieldErrors(config.error))
- return
- }
- setFieldErrors({})
- actions.configure(config.data, onBack)
- }
-
- return (
- !open && onClose()} srTitle={title}>
- {title}
-
-
- {source === 'okta'
- ? 'Use an Okta API token with permission to list users in your organization.'
- : source === 'entra'
- ? 'Register an app in your Microsoft Entra tenant and grant administrator consent for the Microsoft Graph User.Read.All application permission. Use the client secret value.'
- : 'Use profiles from your organization’s existing SCIM provisioning connection.'}
-
- {source === 'okta' && (
- <>
-
-
-
-
- Create an Okta API token
-
-
- >
- )}
- {source === 'entra' && (
- <>
-
-
-
-
-
- Register an application
-
-
- >
- )}
- {source === 'scim' && (
- <>
-
- {() => (
-
-
- {provisioning.isPending
- ? 'Checking provisioning…'
- : provisioning.isError
- ? 'Could not read the provisioning connection.'
- : scimReady
- ? 'Active'
- : 'Enable provisioning before connecting this source.'}
-
- {isOrganizationSettingsSectionAvailable('sso', settingsFeatures) && (
-
- Manage provisioning
-
- )}
- {!provisioning.isFetching && !scimReady && (
-
void provisioning.refetch()}>Check again
- )}
-
- )}
-
- >
- )}
- {source !== 'scim' && (
-
- Sim stores these credentials encrypted for scheduled syncs.
-
- )}
- {disabled && (
-
- Sync is running. You can close this window and return for the result.
-
- )}
- {actions.error?.message}
-
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/people/directory-sync-modal.tsx b/apps/sim/app/o/[organizationId]/people/directory-sync-modal.tsx
deleted file mode 100644
index 062b4589bf0..00000000000
--- a/apps/sim/app/o/[organizationId]/people/directory-sync-modal.tsx
+++ /dev/null
@@ -1,289 +0,0 @@
-import { useState } from 'react'
-import {
- Badge,
- Chip,
- ChipConfirmModal,
- ChipModal,
- ChipModalBody,
- ChipModalDescription,
- ChipModalError,
- ChipModalField,
- ChipModalFooter,
- ChipModalHeader,
-} from '@sim/emcn'
-import type { PeopleSyncConfig, PeopleSyncStatus } from '@/lib/api/contracts/people-sync'
-import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
-import { DirectorySyncForm } from '@/app/o/[organizationId]/people/directory-sync-form'
-import { IMPORT_SOURCES } from '@/app/o/[organizationId]/people/import-source'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import {
- RESOURCE_LIST_STACK,
- SettingsResourceRow,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import {
- isDirectorySyncAccessError,
- usePeopleSyncActions,
- usePeopleSyncStatus,
-} from '@/hooks/queries/people-sync'
-
-const SOURCES = {
- okta: IMPORT_SOURCES.okta,
- entra: IMPORT_SOURCES.entra,
- scim: IMPORT_SOURCES.directory,
-} as const
-const STATES = {
- scheduled: { label: 'Connected', variant: 'green' },
- syncing: { label: 'Syncing', variant: 'amber' },
- queued: { label: 'Queued', variant: 'amber' },
- failed: { label: 'Sync failed', variant: 'red' },
- paused: { label: 'Paused', variant: 'orange' },
-} as const
-const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' })
-const PHASE_LABELS = {
- users: 'Reading people',
- relationships: 'Reading reporting lines',
- publish: 'Validating and publishing',
-} as const
-
-interface DirectorySyncModalProps {
- organizationId: string
- onClose: () => void
-}
-
-interface SyncStatusProps {
- sync: PeopleSyncStatus
-}
-function SyncStatus({ sync }: SyncStatusProps) {
- const paused = sync.state === 'paused'
- const { label, variant } =
- !paused && sync.retryBlocked && sync.state !== 'syncing'
- ? { label: 'Waiting to retry', variant: 'amber' as const }
- : sync.progress && sync.state === 'queued'
- ? { label: 'Refresh in progress', variant: 'blue' as const }
- : STATES[sync.state]
- const { progress } = sync
- const dates = [
- ['Last attempted', sync.lastAttemptAt],
- ['Last successful sync', sync.lastSuccessAt],
- ...(sync.lastFailedAt ? [['Last failed sync', sync.lastFailedAt]] : []),
- ...(!paused
- ? [sync.retryAt ? ['Next retry', sync.retryAt] : ['Next scheduled sync', sync.nextSyncAt]]
- : []),
- ] as const
- return (
- <>
-
- {() => (
-
-
- {SOURCES[sync.source].name}
- {sync.domain && ` · ${sync.domain}`}
-
-
-
- {label}
-
-
-
- )}
-
- {progress && !paused && (
-
- {() => (
-
-
- {PHASE_LABELS[progress.phase]} · {progress.usersRead.toLocaleString()} users read
-
-
- The chart updates after the full directory has been validated.
-
-
- )}
-
- )}
-
- {() => (
-
- {dates.map(([label, value]) => (
-
-
{label}
-
- {value ? {dateFormat.format(new Date(value))} : '—'}
-
-
- ))}
-
- )}
-
- {sync.lastError &&
- (paused ? (
- Last sync error: {sync.lastError}
- ) : (
- {sync.lastError}
- ))}
- >
- )
-}
-
-export function DirectorySyncModal({ organizationId, onClose }: DirectorySyncModalProps) {
- const { settingsFeatures } = useOrganizationContext()
- const { features, hosted } = useDeploymentShape()
- const canProvision = features.scim && (!hosted || settingsFeatures.hasEnterprisePlan)
- const query = usePeopleSyncStatus(organizationId)
- const actions = usePeopleSyncActions(organizationId)
- const [mode, setMode] = useState(null)
- const status = isDirectorySyncAccessError(query.error) ? undefined : query.data
- const sync = status?.sync ?? null
- const activeMode = status ? mode : null
- const goBack = () => {
- setMode(null)
- actions.reset()
- }
- if (activeMode === 'disconnect')
- return (
- !open && goBack()}
- title='Disconnect directory'
- text='Disconnecting removes the profile details and reporting lines supplied by this directory. Sim keeps organization memberships and manually recorded profiles.'
- confirm={{
- label: 'Disconnect',
- pending: actions.pending,
- pendingLabel: 'Disconnecting…',
- onClick: () => actions.disconnect(goBack),
- }}
- >
- {actions.error?.message}
-
- )
- if (activeMode)
- return (
-
- )
- const title = 'Directory sync'
- const syncing = sync?.state === 'syncing' || actions.pending
- const paused = sync?.state === 'paused'
- const waitingToRetry = Boolean(sync?.retryBlocked)
- return (
- !open && onClose()} srTitle={title}>
- {title}
-
- {query.isPending ? (
- Loading directory connection…
- ) : query.isError && !status ? (
- <>
- {query.error.message}
-
- void query.refetch()}>
- Retry connection
-
-
- >
- ) : sync ? (
- <>
-
- {paused
- ? 'SCIM provisioning is disabled. Re-enable it in organization settings to resume updates. Your last published chart remains available.'
- : 'People updates from this directory automatically. You can also start a sync now.'}
-
-
-
- {sync.source !== 'scim' && (
- {
- actions.reset()
- setMode(sync.source)
- }}
- >
- Update credentials
-
- )}
- {
- actions.reset()
- setMode('disconnect')
- }}
- >
- Disconnect
-
-
- {actions.action === 'run' && !paused && (
-
- Sync is running. You can close this window and return for the result.
-
- )}
- {actions.error?.message}
- >
- ) : (
- <>
-
- Connect a directory to keep profiles and reporting lines up to date.
-
-
- {() => (
-
- {(['okta', 'entra', 'scim'] as const).map((source) => {
- if (source === 'scim' && !canProvision) return null
- const { name, icon: Icon } = SOURCES[source]
- return (
- }
- clickLabel={`Connect ${name}`}
- onClick={() => setMode(source)}
- navigable
- />
- )
- })}
-
- )}
-
- >
- )}
-
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/people/import-source.tsx b/apps/sim/app/o/[organizationId]/people/import-source.tsx
deleted file mode 100644
index b1b0d1d0872..00000000000
--- a/apps/sim/app/o/[organizationId]/people/import-source.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-import { Chip, ChipLink, ChipModalDescription, ChipModalField } from '@sim/emcn'
-import { ShieldCheck, Table } from '@sim/emcn/icons'
-import { MicrosoftIcon, OktaIcon } from '@/components/icons'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import {
- RESOURCE_LIST_STACK,
- SettingsResourceRow,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import type { useScimConnection } from '@/ee/scim/hooks/scim'
-import { serializeSsoSettings } from '@/ee/sso/search-params'
-
-export const IMPORT_SOURCES = {
- okta: { name: 'Okta', description: 'Read profiles from your Okta directory', icon: OktaIcon },
- entra: {
- name: 'Microsoft Entra ID',
- description: 'Read profiles from Microsoft Graph',
- icon: MicrosoftIcon,
- },
- directory: {
- name: 'SCIM provisioning',
- description: 'Use directory data already provisioned to Sim',
- icon: ShieldCheck,
- },
- file: { name: 'Spreadsheet', description: 'Upload a CSV or Excel file', icon: Table },
-} as const
-
-export type ImportSource = keyof typeof IMPORT_SOURCES
-
-interface ImportSourcePickerProps {
- canProvision: boolean
- onChoose: (source: ImportSource) => void
-}
-
-export function ImportSourcePicker({ canProvision, onChoose }: ImportSourcePickerProps) {
- return (
- <>
-
- Choose a source for a one-time update to members’ job titles, departments, and reporting
- lines.
-
-
- {() => (
-
- {(['okta', 'entra', 'directory', 'file'] as const).map((source) => {
- if (source === 'directory' && !canProvision) return null
- const { name, description, icon: Icon } = IMPORT_SOURCES[source]
- return (
- }
- title={name}
- description={description}
- clickLabel={name}
- onClick={() => onChoose(source)}
- navigable
- />
- )
- })}
-
- )}
-
- >
- )
-}
-
-interface ProviderFieldsProps {
- source: 'okta' | 'entra'
- domain: string
- onDomainChange: (value: string) => void
- token: string
- onTokenChange: (value: string) => void
- disabled: boolean
-}
-
-export function ProviderFields({
- source,
- domain,
- onDomainChange,
- token,
- onTokenChange,
- disabled,
-}: ProviderFieldsProps) {
- const okta = source === 'okta'
- return (
- <>
-
- {okta
- ? 'Use an Okta API token that can read users in your organization.'
- : 'In Graph Explorer, sign in with your work or school account, grant delegated User.Read.All permission, and copy your access token. Microsoft commercial cloud is supported.'}{' '}
- Sim uses the token for this import only.
-
- {okta && (
-
- )}
-
-
-
- {okta ? 'Create an Okta API token' : 'Open Graph Explorer'}
-
-
- >
- )
-}
-
-interface ProvisioningSourceProps {
- organizationId: string
- connection: ReturnType
- canOpenSettings: boolean
-}
-
-export function ProvisioningSource({
- organizationId,
- connection,
- canOpenSettings,
-}: ProvisioningSourceProps) {
- const directory = connection.data?.connection
- const active = directory?.status === 'active'
- return (
- <>
-
- {connection.isLoading
- ? 'Checking your provisioning connection…'
- : connection.isError
- ? 'Could not load your provisioning connection.'
- : !active
- ? 'Enable SCIM provisioning and assign accounts in your provider to import their profiles.'
- : directory.userCount === 0
- ? 'Your connection is active. Assign accounts to Sim in your identity provider, then refresh here.'
- : `Your connection has ${directory.userCount} provisioned ${directory.userCount === 1 ? 'account' : 'accounts'}. Preview the profile fields and reporting lines sent by your provider.`}
-
-
- {canOpenSettings && !connection.isLoading && !connection.error && (
-
- {active ? 'Manage provisioning' : 'Set up provisioning'}
-
- )}
- {!connection.isLoading && !active && (
-
- Provisioning guide
-
- )}
- {!connection.isLoading && (
- void connection.refetch()}>
- {connection.isError ? 'Retry connection' : 'Refresh'}
-
- )}
-
- >
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/people/loading-status.tsx b/apps/sim/app/o/[organizationId]/people/loading-status.tsx
deleted file mode 100644
index d5bcd1daf60..00000000000
--- a/apps/sim/app/o/[organizationId]/people/loading-status.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Loader } from '@sim/emcn'
-
-interface LoadingStatusProps {
- children: string
-}
-
-export function LoadingStatus({ children }: LoadingStatusProps) {
- return (
-
-
- {children}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/people/loading.tsx b/apps/sim/app/o/[organizationId]/people/loading.tsx
index 0bedf388c48..1a682f3254a 100644
--- a/apps/sim/app/o/[organizationId]/people/loading.tsx
+++ b/apps/sim/app/o/[organizationId]/people/loading.tsx
@@ -1,14 +1,10 @@
-'use client'
-
-import { Users } from '@sim/emcn/icons'
-import { LoadingStatus } from '@/app/o/[organizationId]/people/loading-status'
-import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource'
+import { PeopleSkeleton } from '@/platform/app/(shell)/people/_components/people-page/people-skeleton'
+import { PageSurface } from '@/platform/design-system/components/surface/surface'
export default function PeopleLoading() {
return (
-
-
- Loading people…
-
+
+
+
)
}
diff --git a/apps/sim/app/o/[organizationId]/people/page.tsx b/apps/sim/app/o/[organizationId]/people/page.tsx
index a8927477798..654839e2c96 100644
--- a/apps/sim/app/o/[organizationId]/people/page.tsx
+++ b/apps/sim/app/o/[organizationId]/people/page.tsx
@@ -1,12 +1,12 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
-import { notFound, redirect } from 'next/navigation'
-import { getSession } from '@/lib/auth'
+import { notFound } from 'next/navigation'
import { organizationRoutes } from '@/lib/navigation/paths'
-import { isPeopleEnabled } from '@/lib/people/feature-flag'
-import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
-import PageLoading from '@/app/o/[organizationId]/people/loading'
-import { People } from '@/app/o/[organizationId]/people/people'
+import { canBrowsePeople } from '@/lib/people/feature-flag'
+import { requireOrganizationViewer } from '@/app/o/[organizationId]/viewer'
+import { PeoplePage as PeopleView } from '@/platform/app/(shell)/people/_components/people-page/people-page'
+import { PeopleSkeleton } from '@/platform/app/(shell)/people/_components/people-page/people-skeleton'
+import { PageSurface } from '@/platform/design-system/components/surface/surface'
export const metadata: Metadata = { title: 'People' }
@@ -16,18 +16,16 @@ interface PeoplePageProps {
export default async function PeoplePage({ params }: PeoplePageProps) {
const { organizationId } = await params
- const session = await getSession()
- if (!session?.user)
- redirect(
- buildAuthCrossLink('/login', {
- callbackUrl: organizationRoutes(organizationId).people,
- isInviteFlow: false,
- })
- )
- if (!(await isPeopleEnabled(organizationId))) notFound()
+ const { surface } = await requireOrganizationViewer(
+ organizationId,
+ organizationRoutes(organizationId).people
+ )
+ if (!(await canBrowsePeople(organizationId, surface.viewer.role))) notFound()
return (
- }>
-
-
+
+ }>
+
+
+
)
}
diff --git a/apps/sim/app/o/[organizationId]/people/people-chart.tsx b/apps/sim/app/o/[organizationId]/people/people-chart.tsx
deleted file mode 100644
index d8a07255ebf..00000000000
--- a/apps/sim/app/o/[organizationId]/people/people-chart.tsx
+++ /dev/null
@@ -1,290 +0,0 @@
-'use client'
-
-import {
- Fragment,
- type MouseEvent,
- type ReactNode,
- useCallback,
- useLayoutEffect,
- useRef,
-} from 'react'
-import {
- Button,
- Chip,
- ChipCombobox,
- cn,
- scrollFadeAttributes,
- scrollFadeXClass,
- useScrollEdges,
-} from '@sim/emcn'
-import { ChevronLeft, ChevronRight, Search } from '@sim/emcn/icons'
-import { useQueryStates } from 'nuqs'
-import { EmptyState } from '@/components/empty-state/empty-state'
-import { isApiClientError } from '@/lib/api/client/errors'
-import { ChartCanvas } from '@/app/o/[organizationId]/people/chart-canvas'
-import { ChartEmptyState } from '@/app/o/[organizationId]/people/chart-empty-state'
-import { LoadingStatus } from '@/app/o/[organizationId]/people/loading-status'
-import {
- peopleParsers,
- peopleUrlKeys,
- serializePeopleView,
-} from '@/app/o/[organizationId]/people/search-params'
-import { usePeoplePicker } from '@/app/o/[organizationId]/people/use-people-picker'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { ResourceLoadError } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
-import { usePeopleChart } from '@/hooks/queries/people'
-import { useIsMobile } from '@/hooks/use-is-mobile'
-
-interface PeopleChartProps {
- organizationId: string
- onImport?: () => void
- onSync?: () => void
- viewSwitch: ReactNode
-}
-
-interface FindPersonProps {
- rootsLabel?: string
- organizationId: string
- onSelect: (id: string) => void
-}
-
-function FindPerson({ organizationId, onSelect, rootsLabel }: FindPersonProps) {
- const { search, pickerProps } = usePeoplePicker(organizationId, rootsLabel ? 'roots' : undefined)
- return (
-
-
- Find a person
-
- )
- }
- overlayLabel={rootsLabel ?? 'Find a person'}
- placeholder={rootsLabel ?? 'Find a person'}
- searchPlaceholder={rootsLabel ? 'Search team leads…' : 'Search name, title or email…'}
- className={rootsLabel ? 'max-w-48' : 'w-44 max-w-full'}
- dropdownWidth={300}
- align='start'
- onChange={onSelect}
- emptyMessage={
- rootsLabel && !search ? 'No top-level teams with reporting lines yet' : 'No matching people'
- }
- />
- )
-}
-
-interface ReportingPathProps {
- children: ReactNode
-}
-
-function ReportingPath({ children }: ReportingPathProps) {
- const scrollRef = useRef(null)
- const edges = useScrollEdges(scrollRef, { axis: 'x' })
- return (
-
-
-
- )
-}
-
-export function PeopleChart({ organizationId, viewSwitch, onImport, onSync }: PeopleChartProps) {
- const { organization } = useOrganizationContext()
- const [origin, setView] = useQueryStates(peopleParsers, peopleUrlKeys)
- const { focus, page } = origin
- const size = useIsMobile() ? 1 : 4
- const chart = usePeopleChart(organizationId, focus, Math.max(1, Math.min(page, 1_000_000)), size)
- const notFound = isApiClientError(chart.error) && chart.error.status === 404
- const refused = isApiClientError(chart.error) && chart.error.status < 500
- // A transient refresh failure keeps the chart on screen; a refusal replaces it.
- const branch = refused ? undefined : chart.data
- const profileQuery = serializePeopleView({
- ...origin,
- view: 'chart',
- focus: branch?.person.memberId ?? focus,
- page: branch?.page ?? page,
- panel: null,
- })
- const currentPerson = useRef(null)
- const previousReports = useRef(null)
- const nextReports = useRef(null)
- const pendingFocus = useRef<{
- focus: string
- page: number
- trigger: HTMLElement
- target: 'person' | 'previous' | 'next'
- } | null>(null)
- const focusPerson = useCallback(
- (id: string, event?: MouseEvent) => {
- pendingFocus.current =
- event?.detail === 0
- ? { focus: id, page: 1, trigger: event.currentTarget, target: 'person' }
- : null
- void setView({ focus: id, page: 1, view: 'chart' }, { history: 'push' })
- },
- [setView]
- )
- const changePage = (
- value: number,
- target: 'previous' | 'next',
- event: MouseEvent
- ) => {
- pendingFocus.current =
- event.detail === 0 ? { focus, page: value, trigger: event.currentTarget, target } : null
- void setView({ page: value })
- }
- useLayoutEffect(() => {
- const pending = pendingFocus.current
- if (!branch || !pending || pending.focus !== focus || pending.page !== page) return
- pendingFocus.current = null
- if (document.activeElement !== document.body && document.activeElement !== pending.trigger)
- return
- const controls =
- pending.target === 'next'
- ? [nextReports.current, previousReports.current]
- : [previousReports.current, nextReports.current]
- const target =
- pending.target === 'person'
- ? currentPerson.current
- : (controls.find((button) => button && !button.disabled) ?? currentPerson.current)
- target?.focus()
- }, [branch, focus, page])
- const setCurrentPerson = useCallback((element: HTMLElement | null) => {
- currentPerson.current = element
- }, [])
- return (
- <>
-
- {viewSwitch}
-
-
-
-
-
focusPerson('', event)} disabled={!focus && page === 1}>
- My position
-
-
-
- {branch && !branch.chain.length && !branch.totalReports ? (
-
-
-
- ) : branch ? (
- <>
-
-
-
-
- {branch.chain
- .slice()
- .reverse()
- .map((manager) => (
-
-
- focusPerson(manager.memberId, event)}>
- {manager.name}
-
-
- ))}
-
-
- {branch.person.name}
-
-
-
-
-
- {branch.totalReports
- ? `${branch.totalReports} direct report${branch.totalReports === 1 ? '' : 's'}`
- : 'No direct reports'}
-
- {branch.totalReports > size && (
-
-
- {size === 1
- ? branch.page
- : `${(branch.page - 1) * size + 1}–${(branch.page - 1) * size + branch.reports.length}`}{' '}
- of {branch.totalReports}
-
- changePage(branch.page - 1, 'previous', event)}
- >
-
-
- = branch.totalReports}
- onClick={(event) => changePage(branch.page + 1, 'next', event)}
- >
-
-
-
- )}
-
- >
- ) : chart.error ? (
- notFound ? (
- focusPerson('', event)}>My position
- ) : (
- void setView({ view: 'directory' })}>Open directory
- )
- }
- />
- ) : (
- void chart.refetch()} />
- )
- ) : (
- Loading org chart…
- )}
- >
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/people/people-directory.tsx b/apps/sim/app/o/[organizationId]/people/people-directory.tsx
deleted file mode 100644
index 9f7cac3bf23..00000000000
--- a/apps/sim/app/o/[organizationId]/people/people-directory.tsx
+++ /dev/null
@@ -1,227 +0,0 @@
-'use client'
-
-import { useCallback, useMemo, useState } from 'react'
-import { Avatar, Chip, ChipCombobox } from '@sim/emcn'
-import Link from 'next/link'
-import { useRouter } from 'next/navigation'
-import { useQueryStates } from 'nuqs'
-import { EmptyState } from '@/components/empty-state/empty-state'
-import type { PersonSummary } from '@/lib/api/contracts/people'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { PEOPLE_SEARCH_MAX_LENGTH } from '@/lib/people/validation'
-import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
-import { PEOPLE_COLUMNS, PEOPLE_SEARCH_PLACEHOLDER } from '@/app/o/[organizationId]/people/columns'
-import { LoadingStatus } from '@/app/o/[organizationId]/people/loading-status'
-import {
- peopleParsers,
- peopleUrlKeys,
- serializePeopleView,
-} from '@/app/o/[organizationId]/people/search-params'
-import {
- ResourceLoadError,
- ResourceNoResults,
-} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
-import {
- type FilterTag,
- ResourceFilterPanel,
- ResourceFilterSection,
- type SearchConfig,
-} from '@/app/workspace/[workspaceId]/components/resource/components/resource-options'
-import {
- Resource,
- type ResourceRow,
-} from '@/app/workspace/[workspaceId]/components/resource/resource'
-import { useDepartments, usePeople } from '@/hooks/queries/people'
-import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
-import { useSearchFilterValue } from '@/hooks/use-search-filter-value'
-
-/** The filter's value for every department: stored text can't hold a NUL, so no department is this. */
-const KEY_PREFIX = '\u0000key:'
-const ALL_DEPARTMENTS = '\u0000all'
-
-function personCell(person: PersonSummary) {
- return {
- icon: ,
- label: person.name,
- }
-}
-
-interface PeopleProps {
- organizationId: string
-}
-
-export function PeopleDirectory({ organizationId }: PeopleProps) {
- const router = useRouter()
- const [origin, setView] = useQueryStates(peopleParsers, peopleUrlKeys)
- const { search, department, departmentKey } = origin
- const setSearch = useDebouncedSearchSetter((value, options) =>
- setView({ view: 'directory', search: value }, options)
- )
- const debouncedSearch = useSearchFilterValue(search, SEARCH_DEBOUNCE_MS)
- const query = debouncedSearch.trim()
- const people = usePeople(organizationId, query, department, { departmentKey })
- const profileQuery = serializePeopleView({ ...origin, view: 'directory', panel: null })
- const profileHref = useCallback(
- (memberId: string) => `${organizationRoutes(organizationId).person(memberId)}${profileQuery}`,
- [organizationId, profileQuery]
- )
-
- const [departmentSearch, setDepartmentSearch] = useState('')
- const [departmentsOpen, setDepartmentsOpen] = useState(false)
- const departmentTerm = useSearchFilterValue(departmentSearch, SEARCH_DEBOUNCE_MS).trim()
- const departments = useDepartments(organizationId, departmentTerm, departmentsOpen)
- const departmentWaiting = departmentSearch.trim() !== departmentTerm
- const changingFilter = people.isPlaceholderData || query !== search.trim()
-
- const visiblePages = people.isRefetchError || changingFilter ? undefined : people.data?.pages
- const rows: ResourceRow[] = useMemo(
- () =>
- (visiblePages ?? []).flatMap((page) =>
- page.people.map((person) => ({
- id: person.memberId,
- cells: {
- name: {
- content: (
- event.stopPropagation()}
- className='flex min-w-0 items-center gap-2 rounded-sm text-[var(--text-body)] hover:underline focus-visible:outline-2 focus-visible:outline-[var(--brand-secondary)]'
- >
-
-
- {person.name}
-
-
- ),
- },
- title: { label: person.title },
- department: { label: person.department },
- location: { label: person.location },
- manager: person.manager ? personCell(person.manager) : { label: null },
- },
- }))
- ),
- [visiblePages, profileHref]
- )
-
- const searchConfig: SearchConfig = {
- value: search,
- maxLength: PEOPLE_SEARCH_MAX_LENGTH,
- onChange: setSearch,
- onClearAll: () => setSearch(''),
- placeholder: PEOPLE_SEARCH_PLACEHOLDER,
- }
- const setDepartment = (value: string) => {
- const keyed = value.startsWith(KEY_PREFIX)
- void setView({
- view: 'directory',
- department: keyed ? null : value,
- departmentKey: keyed ? value.slice(KEY_PREFIX.length) : null,
- })
- }
- const hasDepartment = Boolean(departmentKey || department)
- const selectedValue = departmentKey
- ? `${KEY_PREFIX}${departmentKey}`
- : department || ALL_DEPARTMENTS
- const departmentLabel = departmentKey
- ? (!people.isPlaceholderData && people.data?.pages[0]?.selectedDepartment) ||
- 'Selected department'
- : department
- const filterTags: FilterTag[] = hasDepartment
- ? [{ label: `Department: ${departmentLabel}`, onRemove: () => setDepartment('') }]
- : []
-
- return (
- <>
-
-
- page.departments) ?? [])
- ).map(({ name, count, departmentKey: key }) => ({
- value: key ? `${KEY_PREFIX}${key}` : name,
- label: `${name} (${count})`,
- })),
- ]}
- value={selectedValue}
- onChange={(value) => setDepartment(value === ALL_DEPARTMENTS ? '' : value)}
- overlayContent={departmentLabel || 'All'}
- overlayLabel={departmentLabel || 'All'}
- searchable
- searchMaxLength={PEOPLE_SEARCH_MAX_LENGTH}
- searchMode='server'
- onSearchChange={setDepartmentSearch}
- onOpenChange={setDepartmentsOpen}
- hasMore={!departmentWaiting && departments.hasNextPage}
- onLoadMore={() => void departments.fetchNextPage()}
- isLoadingMore={departments.isFetchingNextPage}
- isLoading={departmentWaiting || departments.isPending}
- error={departments.error?.message}
- searchPlaceholder='Search departments...'
- emptyMessage='No departments match'
- className='w-full'
- />
- {departments.isError && (
- void departments.refetch()}
- >
- Retry departments
-
- )}
-
-
- ),
- }}
- filterTags={filterTags}
- />
- router.push(profileHref(memberId))}
- onLoadMore={() => void people.fetchNextPage()}
- hasMore={people.hasNextPage && !changingFilter}
- isLoadingMore={people.isFetchingNextPage}
- isFetching={people.isFetching}
- loadMoreFailed={people.isFetchNextPageError}
- emptyState={
- people.isPending || changingFilter ? (
- Loading people…
- ) : people.isLoadingError || people.isRefetchError ? (
- void people.refetch()}
- />
- ) : !query && !hasDepartment ? (
-
- ) : (
-
-
- void setView({ view: 'directory', search: '', department: '', departmentKey: '' })
- }
- />
-
- )
- }
- />
- >
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/people/people-import-modal.tsx b/apps/sim/app/o/[organizationId]/people/people-import-modal.tsx
deleted file mode 100644
index 7d6bdc7e66e..00000000000
--- a/apps/sim/app/o/[organizationId]/people/people-import-modal.tsx
+++ /dev/null
@@ -1,331 +0,0 @@
-'use client'
-
-import { useRef, useState } from 'react'
-import {
- ChipModal,
- ChipModalBody,
- ChipModalDescription,
- ChipModalError,
- ChipModalField,
- ChipModalFooter,
- ChipModalHeader,
- cn,
- scrollFadeAttributes,
- scrollFadeClass,
- toast,
- useScrollEdges,
-} from '@sim/emcn'
-import { getErrorMessage } from '@sim/utils/errors'
-import { isOrganizationSettingsSectionAvailable } from '@/components/settings/navigation'
-import { isApiClientError } from '@/lib/api/client/errors'
-import type { PeopleImportBody, PeopleImportResult } from '@/lib/api/contracts/people'
-import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
-import { saveBlob } from '@/lib/uploads/client/download'
-import {
- IMPORT_SOURCES,
- type ImportSource,
- ImportSourcePicker,
- ProviderFields,
- ProvisioningSource,
-} from '@/app/o/[organizationId]/people/import-source'
-import {
- type PeopleSheet,
- readPeopleSheet,
-} from '@/app/o/[organizationId]/people/read-people-sheet'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { useScimConnection } from '@/ee/scim/hooks/scim'
-import { useImportPeople } from '@/hooks/queries/people'
-
-const ACCEPTED_FILES = '.csv,.tsv,.xlsx,.xls'
-const TEMPLATE_CSV =
- 'email,title,department,location,start_date,manager_email\nada@example.com,Engineer,Platform,Berlin,2024-01-31,grace@example.com\n'
-
-function downloadTemplate() {
- saveBlob(new Blob([TEMPLATE_CSV], { type: 'text/csv' }), 'people-template.csv')
-}
-
-function plural(count: number, noun: string): string {
- return `${count} ${noun}${count === 1 ? '' : 's'}`
-}
-
-/** The preview's issues in file order, with a count of any past the ones listed. */
-interface ImportIssueListProps {
- result: PeopleImportResult
- spreadsheet: boolean
-}
-
-function ImportIssueList({ result, spreadsheet }: ImportIssueListProps) {
- const listRef = useRef(null)
- const edges = useScrollEdges(listRef)
- const unlisted = result.skipped + result.warnings - result.issues.length
- return (
-
- {result.issues.map((issue) => (
-
- {spreadsheet && Row {issue.row} }{' '}
-
- {issue.email ? `${issue.email}: ` : ''}
- {issue.message}
-
-
- ))}
- {unlisted > 0 && (
- and {plural(unlisted, 'more issue')}
- )}
-
- )
-}
-
-interface PeopleImportModalProps {
- organizationId: string
- onClose: () => void
-}
-
-export function PeopleImportModal({ organizationId, onClose }: PeopleImportModalProps) {
- const [source, setSource] = useState(null)
- const { settingsFeatures } = useOrganizationContext()
- const { features, hosted } = useDeploymentShape()
- const canProvision = features.scim && (!hosted || settingsFeatures.hasEnterprisePlan)
-
- if (source && (source !== 'directory' || canProvision)) {
- return (
- setSource(null)}
- canOpenSettings={isOrganizationSettingsSectionAvailable('sso', settingsFeatures)}
- />
- )
- }
-
- return (
- !open && onClose()} srTitle='Import people data'>
- Import people data
-
-
-
-
-
- )
-}
-
-interface SourceImportModalProps extends PeopleImportModalProps {
- source: ImportSource
- onBack: () => void
- canOpenSettings: boolean
-}
-
-function SourceImportModal({
- organizationId,
- source,
- onBack,
- onClose,
- canOpenSettings,
-}: SourceImportModalProps) {
- const connection = useScimConnection(organizationId, source === 'directory')
- const importPeople = useImportPeople(organizationId)
- const [domain, setDomain] = useState('')
- const [token, setToken] = useState('')
- const [sheet, setSheet] = useState(null)
- const [reading, setReading] = useState(false)
- const [error, setError] = useState(null)
- const [preview, setPreview] = useState(null)
- const busy = useRef(false)
- const pending = reading || importPeople.isPending
- const provider = source === 'okta' || source === 'entra'
- const ready =
- source === 'file'
- ? Boolean(sheet)
- : source === 'directory'
- ? connection.isSuccess &&
- connection.data.connection?.status === 'active' &&
- connection.data.connection.userCount > 0
- : Boolean(token.trim() && (source !== 'okta' || domain.trim()))
- const sourceName = IMPORT_SOURCES[source].name
- const title = preview
- ? 'Review import'
- : source === 'file'
- ? 'Import a spreadsheet'
- : `Import from ${sourceName}`
-
- const submit = async (apply: boolean, file?: File) => {
- if (busy.current || (apply && !preview?.updated)) return
- busy.current = true
- setError(null)
- try {
- let body: PeopleImportBody
- if (source === 'file') {
- let selected = sheet
- if (file) {
- setReading(true)
- setSheet(null)
- selected = await readPeopleSheet(file)
- setSheet(selected)
- }
- if (!selected) return
- body = { headers: selected.headers, rows: selected.rows, apply }
- } else if (source === 'okta') {
- body = {
- source,
- domain: domain.trim(),
- token: token.trim(),
- apply,
- previewDigest: apply ? preview?.previewDigest : undefined,
- }
- } else if (source === 'entra') {
- body = {
- source,
- token: token.trim(),
- apply,
- previewDigest: apply ? preview?.previewDigest : undefined,
- }
- } else {
- body = { source, apply, previewDigest: apply ? preview?.previewDigest : undefined }
- }
- const result = await importPeople.submit(body)
- if (apply) {
- toast.success(`Updated ${plural(result.updated, 'profile')}`)
- onClose()
- } else {
- setPreview(result)
- }
- } catch (cause) {
- if (source !== 'file' && apply && isApiClientError(cause) && cause.status === 409)
- setPreview(null)
- setError(getErrorMessage(cause, 'Could not import people data'))
- } finally {
- busy.current = false
- setReading(false)
- }
- }
-
- const goBack = () => {
- if (busy.current) return
- if (preview) {
- setPreview(null)
- setError(null)
- } else {
- onBack()
- }
- }
-
- const done = preview?.updated === 0
- return (
- !open && onClose()}
- dismissDisabled={pending}
- srTitle={title}
- >
- {title}
-
- {preview ? (
- <>
-
- {source === 'file' ? sheet?.fileName : sourceName}
-
-
- {() => (
-
-
- {done
- ? 'No profiles to update.'
- : `${plural(preview.updated, 'profile')} will be updated.`}
-
-
- {[
- `${preview.unchanged} unchanged`,
- `${preview.skipped} skipped`,
- plural(preview.warnings, 'warning'),
- ].join(' · ')}
-
- {preview.issues.length > 0 && (
-
- )}
-
- )}
-
- {!done && (
-
- {source !== 'file'
- ? 'Import updates matching members’ profiles. If the directory changes, you’ll be asked to preview it again.'
- : 'Import applies these profile updates to matching members.'}
-
- )}
- >
- ) : provider ? (
- {
- setDomain(value)
- setError(null)
- }}
- token={token}
- onTokenChange={(value) => {
- setToken(value)
- setError(null)
- }}
- disabled={pending}
- />
- ) : source === 'directory' ? (
-
- ) : (
- file && void submit(false, file)}
- />
- )}
- {error}
-
- void submit(Boolean(preview)),
- }}
- />
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/people/people.tsx b/apps/sim/app/o/[organizationId]/people/people.tsx
deleted file mode 100644
index 6e3b3ed806a..00000000000
--- a/apps/sim/app/o/[organizationId]/people/people.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { ChipSwitch } from '@sim/emcn'
-import { List, RefreshCw, Users, Workflow } from '@sim/emcn/icons'
-import dynamic from 'next/dynamic'
-import { useQueryStates } from 'nuqs'
-import { IMPORT_PEOPLE_ACTION } from '@/app/o/[organizationId]/people/columns'
-import { PeopleDirectory } from '@/app/o/[organizationId]/people/people-directory'
-import { PeopleImportModal } from '@/app/o/[organizationId]/people/people-import-modal'
-import { peopleParsers, peopleUrlKeys } from '@/app/o/[organizationId]/people/search-params'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource'
-
-const PeopleChart = dynamic(() =>
- import('@/app/o/[organizationId]/people/people-chart').then((module) => module.PeopleChart)
-)
-const DirectorySyncModal = dynamic(() =>
- import('@/app/o/[organizationId]/people/directory-sync-modal').then(
- (module) => module.DirectorySyncModal
- )
-)
-const VIEWS = [
- { value: 'chart', label: 'Org chart', icon: Workflow },
- { value: 'directory', label: 'Directory', icon: List },
-] as const
-
-interface PeopleProps {
- organizationId: string
-}
-
-export function People({ organizationId }: PeopleProps) {
- const { viewer } = useOrganizationContext()
- const [{ view, search, department, departmentKey, panel }, setView] = useQueryStates(
- peopleParsers,
- peopleUrlKeys
- )
- const [importOpen, setImportOpen] = useState(false)
- const openSync = () => void setView({ panel: 'sync' }, { history: 'push' })
- const currentView = view ?? (search || department || departmentKey ? 'directory' : 'chart')
-
- const viewSwitch = (
- void setView({ view: next }, { history: 'push' })}
- />
- )
-
- return (
-
- setImportOpen(true),
- },
- ]
- : []
- }
- />
- {currentView === 'chart' ? (
- setImportOpen(true) : undefined}
- onSync={viewer.isAdmin ? openSync : undefined}
- />
- ) : (
- <>
- {viewSwitch}
-
- >
- )}
- {importOpen && !panel && viewer.isAdmin && (
- setImportOpen(false)} />
- )}
- {panel === 'sync' && viewer.isAdmin && (
- void setView({ panel: null })}
- />
- )}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/people/use-people-picker.tsx b/apps/sim/app/o/[organizationId]/people/use-people-picker.tsx
deleted file mode 100644
index 36b77209cff..00000000000
--- a/apps/sim/app/o/[organizationId]/people/use-people-picker.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { useState } from 'react'
-import { Avatar } from '@sim/emcn'
-import { PEOPLE_SEARCH_MAX_LENGTH } from '@/lib/people/validation'
-import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
-import { usePeople } from '@/hooks/queries/people'
-import { useDebounce } from '@/hooks/use-debounce'
-
-/** Shared, paginated directory search for chart navigation and editing a person's manager. */
-export function usePeoplePicker(
- organizationId: string,
- scope?: 'roots' | 'members',
- excludeMemberId?: string
-) {
- const [search, setSearch] = useState('')
- const [open, setOpen] = useState(false)
- const term = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS)
- const query = usePeople(organizationId, term, '', { scope, enabled: open })
- const waiting = search.trim() !== term || query.isPlaceholderData
- const people =
- waiting || query.isRefetchError
- ? []
- : (query.data?.pages ?? []).flatMap((page) =>
- page.people.filter((person) => person.memberId !== excludeMemberId)
- )
- return {
- search,
- people,
- pickerProps: {
- searchable: true,
- searchMaxLength: PEOPLE_SEARCH_MAX_LENGTH,
- searchMode: 'server' as const,
- onSearchChange: setSearch,
- onOpenChange: setOpen,
- options: people.map((person) => ({
- value: person.memberId,
- label: [person.name, person.title, person.email].filter(Boolean).join(' · '),
- iconElement: ,
- })),
- hasMore: query.hasNextPage && !waiting,
- onLoadMore: () => void query.fetchNextPage(),
- isLoading: waiting || (query.isFetching && !query.isFetchingNextPage),
- isLoadingMore: query.isFetchingNextPage,
- error: query.error?.message,
- },
- }
-}
diff --git a/apps/sim/app/o/[organizationId]/route-boundaries.test.tsx b/apps/sim/app/o/[organizationId]/route-boundaries.test.tsx
new file mode 100644
index 00000000000..2c214bf78df
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/route-boundaries.test.tsx
@@ -0,0 +1,57 @@
+/** @vitest-environment jsdom */
+
+import { act, type ReactNode } from 'react'
+import { stubResizeObserver } from '@sim/testing/mocks/browser-apis.mock'
+import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied'
+import OrganizationError from '@/app/o/[organizationId]/error'
+import OrganizationNotFound from '@/app/o/[organizationId]/not-found'
+
+vi.mock('next/navigation', () => nextNavigationMock)
+vi.mock('@/app/_shell/desktop-title-bar', () => ({ DesktopTitleBarLane: () => null }))
+
+const mountedRoots: Root[] = []
+
+function mount(element: ReactNode) {
+ const container = document.body.appendChild(document.createElement('div'))
+ const root = createRoot(container)
+ mountedRoots.push(root)
+ act(() => root.render(element))
+ return container
+}
+
+// A root left mounted lets React's scheduler run after jsdom's teardown removes `window`.
+afterEach(() => {
+ act(() => {
+ for (const root of mountedRoots.splice(0)) root.unmount()
+ })
+ document.body.replaceChildren()
+})
+
+/**
+ * Failure mode: the thing that failed is what a boundary would otherwise read. Each renders here
+ * with no OrganizationProvider, query client, feature flags or store seeded around it.
+ */
+describe('organization route boundaries with nothing around them', () => {
+ beforeEach(() => {
+ stubResizeObserver()
+ })
+
+ it('error offers Try again', () => {
+ const page = mount( )
+ expect(page.querySelector('button')?.textContent).toBe('Try again')
+ })
+
+ it('not found offers a way home', () => {
+ const page = mount( )
+ expect(page.querySelector('h1')?.textContent).toBe('Page not found')
+ expect(page.querySelector('a')?.textContent).toBe('Return home')
+ })
+
+ it('access denied offers the workspaces', () => {
+ const page = mount( )
+ expect(page.querySelector('a')?.textContent).toBe('View your workspaces')
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/search/chat/[chatId]/loading.tsx b/apps/sim/app/o/[organizationId]/search/chat/[chatId]/loading.tsx
new file mode 100644
index 00000000000..e2510b0eaa6
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/search/chat/[chatId]/loading.tsx
@@ -0,0 +1,6 @@
+import { ConversationSkeleton } from '@/platform/app/(shell)/_components/conversation/skeletons'
+
+/** Keeps Search's landing boundary from standing in for a saved conversation. */
+export default function OrganizationSearchChatLoading() {
+ return
+}
diff --git a/apps/sim/app/o/[organizationId]/search/chat/[chatId]/page.tsx b/apps/sim/app/o/[organizationId]/search/chat/[chatId]/page.tsx
index b889fb41c7f..68260c493d9 100644
--- a/apps/sim/app/o/[organizationId]/search/chat/[chatId]/page.tsx
+++ b/apps/sim/app/o/[organizationId]/search/chat/[chatId]/page.tsx
@@ -1,12 +1,11 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
-import { getSession } from '@/lib/auth'
import { getAccessibleCopilotChatAuth } from '@/lib/mothership/chat/lifecycle'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
-import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
-import { OrganizationSearch } from '@/app/o/[organizationId]/search/search'
-import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
+import { requireOrganizationViewer } from '@/app/o/[organizationId]/viewer'
+import { ConversationSkeleton } from '@/platform/app/(shell)/_components/conversation/skeletons'
+import { OrganizationSearchSurface } from '@/platform/app/(shell)/search/_components/search-surface/search-surface'
export const metadata: Metadata = { title: 'Assistant Search' }
@@ -16,20 +15,22 @@ export default async function OrganizationSearchChatPage({
params: Promise<{ organizationId: string; chatId: string }>
}) {
const { organizationId, chatId } = await params
- const session = await getSession()
- if (!session?.user?.id) notFound()
- const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
- if (!context) notFound()
+ const {
+ userId,
+ principal,
+ surface: context,
+ } = await requireOrganizationViewer(
+ organizationId,
+ organizationRoutes(organizationId).searchChat(chatId)
+ )
if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
if (!context.mothershipAvailable) redirect(organizationRoutes(organizationId).search)
- const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id, {
- principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id },
- })
+ const chat = await getAccessibleCopilotChatAuth(chatId, userId, { principal })
if (!chat || chat.type !== 'mothership' || chat.organizationId !== organizationId) notFound()
if (chat.mode !== 'assistant') redirect(organizationRoutes(organizationId).chat(chatId))
return (
- }>
-
+ }>
+
)
}
diff --git a/apps/sim/app/o/[organizationId]/search/loading.tsx b/apps/sim/app/o/[organizationId]/search/loading.tsx
new file mode 100644
index 00000000000..469b4532e3e
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/search/loading.tsx
@@ -0,0 +1,6 @@
+import { LandingSkeleton } from '@/platform/app/(shell)/_components/conversation/skeletons'
+
+/** A route boundary cannot read `mode`, so it holds the Search view, where the route opens. */
+export default function OrganizationSearchLoading() {
+ return
+}
diff --git a/apps/sim/app/o/[organizationId]/search/page.tsx b/apps/sim/app/o/[organizationId]/search/page.tsx
index 7f33f99e1e5..dcc9a959e21 100644
--- a/apps/sim/app/o/[organizationId]/search/page.tsx
+++ b/apps/sim/app/o/[organizationId]/search/page.tsx
@@ -1,28 +1,50 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
-import { notFound, redirect } from 'next/navigation'
-import { getSession } from '@/lib/auth'
-import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
-import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
-import { OrganizationSearch } from '@/app/o/[organizationId]/search/search'
-import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
+import { redirect } from 'next/navigation'
+import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
+import { loadOrganizationSearchView } from '@/lib/organizations/search-params'
+import { requireOrganizationViewer } from '@/app/o/[organizationId]/viewer'
+import {
+ LandingSkeleton,
+ ResultsSkeleton,
+} from '@/platform/app/(shell)/_components/conversation/skeletons'
+import { OrganizationSearchSurface } from '@/platform/app/(shell)/search/_components/search-surface/search-surface'
+import { Hydrated } from '@/platform/design-system/components/surface/hydrated'
+import { prefetchSearch } from '@/platform/lib/data/prefetch'
export const metadata: Metadata = { title: 'Search' }
export default async function OrganizationSearchPage({
params,
+ searchParams,
}: {
params: Promise<{ organizationId: string }>
+ searchParams: Promise>
}) {
- const { organizationId } = await params
- const session = await getSession()
- if (!session?.user?.id) notFound()
- const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
- if (!context) notFound()
+ const [{ organizationId }, query] = await Promise.all([params, searchParams])
+ const {
+ userId,
+ principal,
+ surface: context,
+ } = await requireOrganizationViewer(organizationId, organizationRoutes(organizationId).search)
if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
+ const surface =
+ const { q, mode } = loadOrganizationSearchView(query)
+ const fallback =
+ mode === 'assistant' ? (
+
+ ) : q.trim() ? (
+
+ ) : (
+
+ )
return (
- }>
-
+
+ {q.trim() || mode === 'assistant' ? (
+ surface
+ ) : (
+ {surface}
+ )}
)
}
diff --git a/apps/sim/app/o/[organizationId]/search/search-frame.tsx b/apps/sim/app/o/[organizationId]/search/search-frame.tsx
new file mode 100644
index 00000000000..71b72044faf
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/search/search-frame.tsx
@@ -0,0 +1,58 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { cn } from '@sim/emcn'
+import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
+import type { OrganizationSearchState } from '@/platform/lib/data/organization-search'
+
+interface SearchFrameProps {
+ search: OrganizationSearchState
+ /** The view switch and New control, shown over both views. */
+ header: ReactNode
+ /** The Search view. It renders beside the conversation, never around it. */
+ children: ReactNode
+ /** The Assistant view, mounted beside the Search view once it has been opened. */
+ assistant: ReactNode
+ /** Shown while the viewer's saved view has not resolved. */
+ unresolvedFallback: ReactNode
+}
+
+/**
+ * Hosts the two views of Search. Assistant stays mounted and `inert` while Search shows, so a running
+ * answer keeps streaming and its draft survives a switch.
+ */
+export function SearchFrame({
+ search,
+ header,
+ children,
+ assistant,
+ unresolvedFallback,
+}: SearchFrameProps) {
+ const { activeMode } = search
+ return (
+
+
+ {search.assistantMounted && (
+
+ {assistant}
+
+ )}
+ {!activeMode && unresolvedFallback}
+ {activeMode === 'search' && (
+ <>
+
+ {header}
+
+
{children}
+ >
+ )}
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/search/search-params.ts b/apps/sim/app/o/[organizationId]/search/search-params.ts
deleted file mode 100644
index 40faaad7f1f..00000000000
--- a/apps/sim/app/o/[organizationId]/search/search-params.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
-
-export const organizationSearchParsers = {
- q: parseAsString.withDefault(''),
- mode: parseAsStringLiteral(['search', 'assistant']),
-} as const
-
-/** A query is a filter-like view change, not navigation: replace, and clear when empty. */
-export const organizationSearchUrlKeys = {
- history: 'replace',
- clearOnDefault: true,
-} as const
diff --git a/apps/sim/app/o/[organizationId]/search/search-results-view.tsx b/apps/sim/app/o/[organizationId]/search/search-results-view.tsx
deleted file mode 100644
index ff4b36973a0..00000000000
--- a/apps/sim/app/o/[organizationId]/search/search-results-view.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import { type ReactNode, useRef } from 'react'
-import {
- cn,
- overflowFadeSizeClass,
- scrollFadeAttributes,
- scrollFadeClass,
- useScrollEdges,
-} from '@sim/emcn'
-import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
-import type { SearchResource } from '@/lib/mothership/generated/resources'
-import { OrganizationLanding } from '@/app/o/[organizationId]/components/organization-landing'
-import { PAGE_COLUMN_CLASS } from '@/app/o/[organizationId]/components/organization-page'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results'
-import {
- SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
- SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
-} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
-
-interface SearchResultsViewProps {
- composer: ReactNode
- query: string
- onSummarize?: (message: string, filters: WorkspaceSearchFilters) => void
- onSearchChange?: (search: SearchResource) => void
-}
-
-/** Standalone Search uses the original centered field and docked results layout. */
-export function SearchResultsView({
- composer,
- query,
- onSummarize,
- onSearchChange,
-}: SearchResultsViewProps) {
- const { organization } = useOrganizationContext()
- const scope = { kind: 'organization' as const, organizationId: organization.id }
- const scrollContainerRef = useRef(null)
- const scrollContentRef = useRef(null)
- const searching = query.trim().length > 0
- const scrollEdges = useScrollEdges(scrollContainerRef, {
- contentRef: scrollContentRef,
- enabled: searching,
- })
- return (
-
- {searching ? (
- <>
-
- {composer}
-
-
- {/* The rows carry their own `px-2`; this gutter brings each row's mark under the
- field's own search glyph, so results read as a column hanging from the field. */}
-
-
-
-
- >
- ) : (
-
- {composer}
-
- )}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx
deleted file mode 100644
index 1f36e12bf8f..00000000000
--- a/apps/sim/app/o/[organizationId]/search/search.tsx
+++ /dev/null
@@ -1,320 +0,0 @@
-'use client'
-
-import { lazy, Suspense, useCallback, useEffect, useRef, useState, useTransition } from 'react'
-import { Chip, ComposerActionButton, cn } from '@sim/emcn'
-import { ArrowUp, Plus, Search, Sparkles, StopFilled } from '@sim/emcn/icons'
-import { generateId } from '@sim/utils/id'
-import { useIsFetching, useQueryClient } from '@tanstack/react-query'
-import { usePathname, useRouter, useSearchParams } from 'next/navigation'
-import { useQueryStates } from 'nuqs'
-import { HEADER_ACTION_CLUSTER } from '@/components/page-header-bar'
-import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
-import { resourceScopeKey } from '@/lib/core/resource-scope'
-import { BrowserStorage } from '@/lib/core/utils/browser-storage'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import type { OrganizationSummaryRequest } from '@/app/o/[organizationId]/components/organization-conversation'
-import { SearchInputBar } from '@/app/o/[organizationId]/components/search-input-bar'
-import { SearchLandingHistory } from '@/app/o/[organizationId]/components/search-landing-history'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import {
- organizationSearchParsers,
- organizationSearchUrlKeys,
-} from '@/app/o/[organizationId]/search/search-params'
-import { SearchResultsView } from '@/app/o/[organizationId]/search/search-results-view'
-import { useSearchHistoryActions } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-history-context'
-import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
-import { MicButton } from '@/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button'
-import { MicrophonePermissionHelp } from '@/app/workspace/[workspaceId]/home/components/user-input/components/microphone-permission-help/microphone-permission-help'
-import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
-import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
-import { useHydrated } from '@/hooks/use-hydrated'
-import { useVoiceInput } from '@/hooks/use-voice-input'
-import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
-
-const OrganizationConversation = lazy(() =>
- import('@/app/o/[organizationId]/components/organization-conversation').then((module) => ({
- default: module.OrganizationConversation,
- }))
-)
-
-const SEARCH_MODES = [
- { value: 'search', label: 'Search', icon: Search },
- { value: 'assistant', label: 'Assistant', icon: Sparkles },
-] as const
-
-interface OrganizationSearchProps {
- userId: string
- chatId?: string
-}
-
-interface SearchFieldProps {
- userId: string
- initialValue: string
- onSubmit: (value: string) => void
-}
-
-/** Search commits a query on submit while retaining an independent editable draft. */
-function SearchField({ userId, initialValue, onSubmit }: SearchFieldProps) {
- const inputRef = useRef(null)
- const hydrated = useHydrated()
- const { organization } = useOrganizationContext()
- const queryClient = useQueryClient()
- const queryKey = knowledgeKeys.searchQuery(
- resourceScopeKey({ kind: 'organization', organizationId: organization.id }),
- initialValue.trim(),
- userId
- )
- const isSearching = useIsFetching({ queryKey }) > 0
- const latestDraftKey = `${userId}:organization:${organization.id}:search`
- const latestDraft = useMothershipDraftsStore((state) => state.drafts[latestDraftKey])
- const ownerQuery = initialValue || latestDraft?.searchQuery || ''
- const draftKey = `${latestDraftKey}:query:${encodeURIComponent(ownerQuery)}`
- const draft = useMothershipDraftsStore((state) => state.drafts[draftKey])
- const value =
- draft?.text ?? (latestDraft?.searchQuery === ownerQuery ? latestDraft.text : initialValue)
- const setValue = (text: string) => {
- const { setDraft } = useMothershipDraftsStore.getState()
- const payload = { text, searchQuery: ownerQuery }
- setDraft(draftKey, payload)
- setDraft(latestDraftKey, payload)
- }
- const submit = (text = value) => {
- if (!text.trim() || (isSearching && text.trim() === initialValue.trim())) return
- const { clearDraft } = useMothershipDraftsStore.getState()
- if (text.trim() === value.trim()) {
- clearDraft(draftKey)
- if (latestDraft?.searchQuery === ownerQuery) clearDraft(latestDraftKey)
- }
- clearDraft(`${latestDraftKey}:query:${encodeURIComponent(text.trim())}`)
- onSubmit(text)
- }
- const voice = useVoiceInput({
- organizationId: organization.id,
- getValue: () => value,
- onChange: setValue,
- })
- const canSubmit = hydrated && value.trim().length > 0
-
- useEffect(() => {
- if (hydrated) inputRef.current?.focus()
- }, [hydrated])
-
- const field = (
- submit()}
- floating={!initialValue.trim()}
- voiceControl={
- voice.isSupported && (
-
- )
- }
- submitControl={
- (isSearching ? void queryClient.cancelQueries({ queryKey }) : submit())}
- disabled={!canSubmit && !isSearching}
- aria-label={isSearching ? 'Stop search' : 'Search'}
- aria-busy={isSearching}
- active={canSubmit || isSearching}
- >
- {isSearching ? (
-
- ) : (
-
- )}
-
- }
- />
- )
- return (
- <>
- {!initialValue.trim() ? (
- submit(query)}
- >
- {field}
-
- ) : (
- field
- )}
-
- >
- )
-}
-
-/** Organization Search owns both document results and assistant conversations. */
-export function OrganizationSearch(props: OrganizationSearchProps) {
- const { searchAccess } = useOrganizationContext()
- if (!searchAccess.memberScoped) return null
- return
-}
-
-function OrganizationSearchContent({ userId, chatId }: OrganizationSearchProps) {
- const { organization, mothershipAvailable } = useOrganizationContext()
- const { recordQuery } = useSearchHistoryActions()
- const router = useRouter()
- const pathname = usePathname()
- const hasSearchParams = useSearchParams().toString() !== ''
- const routes = organizationRoutes(organization.id)
- const hasConversation = Boolean(chatId) || pathname.startsWith(`${routes.search}/chat/`)
- const queryClient = useQueryClient()
- const [{ q, mode }, setParams] = useQueryStates(
- organizationSearchParsers,
- organizationSearchUrlKeys
- )
- const routeMode = mode ?? (hasConversation ? 'assistant' : hasSearchParams ? 'search' : null)
- const activeMode = mothershipAvailable ? routeMode : 'search'
- const preferenceKey = `organization-search-mode:${userId}:${organization.id}`
- const [assistantOpened, setAssistantOpened] = useState(activeMode === 'assistant')
- const [conversationInstance, setConversationInstance] = useState(0)
- const [isStartingNew, startNewTransition] = useTransition()
- const [summaries, setSummaries] = useState([])
- const consumeSummary = useCallback(
- (id: string) => setSummaries((current) => current.filter((request) => request.id !== id)),
- []
- )
- useEffect(() => {
- if (!mothershipAvailable) return
- if (routeMode) {
- BrowserStorage.setItem(preferenceKey, routeMode)
- return
- }
- const savedMode = BrowserStorage.getItem(preferenceKey, null)
- // Pin the mode to this history entry so Back survives a later preference change.
- void setParams({ mode: savedMode === 'assistant' ? 'assistant' : 'search' })
- }, [mothershipAvailable, preferenceKey, routeMode, setParams])
-
- if (activeMode === 'assistant' && !assistantOpened) setAssistantOpened(true)
-
- const changeMode = (next: 'search' | 'assistant') => {
- void setParams({ mode: next }, { history: 'push' })
- }
- const startNew = () => {
- setSummaries([])
- const { clearDraft } = useMothershipDraftsStore.getState()
- const scope = `${userId}:organization:${organization.id}`
- if (activeMode === 'assistant') {
- clearDraft(`${scope}:search-assistant`)
- } else {
- clearDraft(`${scope}:search`)
- clearDraft(`${scope}:search:query:`)
- }
- startNewTransition(() => {
- setConversationInstance((current) => current + 1)
- router.push(mothershipAvailable ? `${routes.search}?mode=${activeMode}` : routes.search, {
- scroll: false,
- })
- })
- }
- const summarize = (message: string, assistantSearch: WorkspaceSearchFilters) => {
- setSummaries((current) => [...current, { id: generateId(), message, assistantSearch }])
- changeMode('assistant')
- }
-
- const submit = (draft: string) => {
- const next = draft.trim()
- if (!next) return
- recordQuery(next)
- if (next === q.trim()) {
- void queryClient.invalidateQueries({
- queryKey: knowledgeKeys.searchQuery(
- resourceScopeKey({ kind: 'organization', organizationId: organization.id }),
- next,
- userId
- ),
- })
- } else {
- void setParams({ q: next })
- }
- }
- const newLabel = activeMode === 'assistant' ? 'New chat' : 'New search'
- const header = (
- <>
-
- {SEARCH_MODES.filter((entry) => mothershipAvailable || entry.value === 'search').map(
- (entry) => (
- changeMode(entry.value)}
- >
- {entry.label}
-
- )
- )}
-
- {(hasConversation || (activeMode === 'search' && q.trim())) && (
-
- {newLabel}
- New
-
- )}
- >
- )
- return (
-
-
- {(assistantOpened || hasConversation) && mothershipAvailable && (
-
- }>
-
-
-
- )}
- {!activeMode &&
}
- {activeMode === 'search' && (
- <>
-
- {header}
-
-
-
- }
- query={q.trim()}
- onSummarize={mothershipAvailable ? summarize : undefined}
- />
-
- >
- )}
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/layout.test.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/layout.test.tsx
index 22b6cb77330..3ef39f1f40d 100644
--- a/apps/sim/app/o/[organizationId]/settings/[section]/layout.test.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/layout.test.tsx
@@ -2,10 +2,6 @@ import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
import { describe, expect, it, vi } from 'vitest'
vi.mock('next/navigation', () => nextNavigationMock)
-vi.mock('@/components/settings/settings-header', () => ({
- SettingsHeaderProvider: () => null,
- SettingsHeaderShell: () => null,
-}))
import OrganizationSettingsSectionLayout from '@/app/o/[organizationId]/settings/[section]/layout'
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx
index a75b393eee1..a76be6b3e61 100644
--- a/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx
@@ -1,8 +1,7 @@
import type { ReactNode } from 'react'
import { notFound, redirect } from 'next/navigation'
-import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
import { organizationRoutes } from '@/lib/navigation/paths'
-import { resolveOrganizationSurfaceHeaderMeta } from '@/app/o/[organizationId]/settings/navigation'
+import { resolveOrganizationSurfaceHeaderMeta } from '@/platform/lib/data/settings-sections'
interface OrganizationSettingsSectionLayoutProps {
children: ReactNode
@@ -19,12 +18,7 @@ export default async function OrganizationSettingsSectionLayout({
`${organizationRoutes(organizationId).settingsSection('general')}?view=authorized-apps`
)
}
- const meta = resolveOrganizationSurfaceHeaderMeta(section)
- if (!meta) notFound()
-
- return (
-
- {children}
-
- )
+ if (!resolveOrganizationSurfaceHeaderMeta(section)) notFound()
+ /** Every section draws its own heading in its surface. */
+ return children
}
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/page.test.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/page.test.tsx
index e9906bbaed4..46b249ea456 100644
--- a/apps/sim/app/o/[organizationId]/settings/[section]/page.test.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/page.test.tsx
@@ -9,14 +9,14 @@ vi.mock('next/navigation', () => nextNavigationMock)
vi.mock('@/lib/settings/application/organization-section-access', () => ({
authorizeOrganizationSettingsSection: mocks.authorize,
}))
-vi.mock('@/components/settings/account-settings-renderer', () => ({
- AccountSettingsRenderer: () => null,
-}))
vi.mock('@/components/settings/prefetch-standalone-general', () => ({
prefetchStandaloneGeneral: vi.fn(),
}))
-vi.mock('@/app/o/[organizationId]/settings/[section]/settings', () => ({
- OrganizationSettings: () => null,
+vi.mock('@/platform/app/(shell)/settings/_components/settings-section-host', () => ({
+ PlatformSettingsSectionHost: () => null,
+}))
+vi.mock('@/platform/lib/data/prefetch-permission-editor', () => ({
+ prefetchPermissionEditorCatalog: vi.fn(),
}))
import OrganizationSettingsSectionPage from '@/app/o/[organizationId]/settings/[section]/page'
@@ -31,7 +31,7 @@ describe('organization settings access', () => {
})
it('conceals requests from viewers rejected by the organization gate', async () => {
- mocks.authorize.mockResolvedValue(false)
+ mocks.authorize.mockImplementation(async ({ section }) => section !== 'requests')
await expect(
OrganizationSettingsSectionPage({
params: Promise.resolve({ organizationId: 'organization', section: 'access-control' }),
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
index 06fc89bc220..b3fd5e7e8b3 100644
--- a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx
@@ -1,23 +1,23 @@
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
-import { AccountSettingsRenderer } from '@/components/settings/account-settings-renderer'
import {
getOrganizationSettingsHref,
getSettingsSectionMeta,
ORGANIZATION_SETTINGS_ITEMS,
} from '@/components/settings/navigation'
import { prefetchStandaloneGeneral } from '@/components/settings/prefetch-standalone-general'
-import { SettingsSectionProvider } from '@/components/settings/settings-panel'
import { getSession } from '@/lib/auth'
import { organizationRoutes } from '@/lib/navigation/paths'
import { isPlatformAdmin } from '@/lib/permissions/super-user'
import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
-import { OrganizationSettings } from '@/app/o/[organizationId]/settings/[section]/settings'
-import { resolveOrganizationSurfaceSection } from '@/app/o/[organizationId]/settings/navigation'
import { getLegacyAccessRequestsQuery } from '@/ee/access-requests/lib/navigation'
+import { isPlatformSettingsSection } from '@/platform/app/(shell)/settings/_components/platform-sections'
+import { PlatformSettingsSectionHost } from '@/platform/app/(shell)/settings/_components/settings-section-host'
+import { prefetchPermissionEditorCatalog } from '@/platform/lib/data/prefetch-permission-editor'
+import { resolveOrganizationSurfaceSection } from '@/platform/lib/data/settings-sections'
interface OrganizationSettingsSectionPageProps {
params: Promise<{ organizationId: string; section: string }>
@@ -40,8 +40,7 @@ export async function generateMetadata({
/**
* One settings surface for the organization view. An organization section runs
- * through the organization gate; account sections use the account renderer,
- * with platform administration restricted to platform admins.
+ * through the organization gate; platform administration is restricted to platform admins.
*/
export default async function OrganizationSettingsSectionPage({
params,
@@ -79,22 +78,32 @@ export default async function OrganizationSettingsSectionPage({
if (legacyRequestsQuery) {
redirect(getOrganizationSettingsHref(organizationId, 'requests', legacyRequestsQuery))
}
- return
+ if (resolved.section === 'access-control') {
+ const state = await prefetchPermissionEditorCatalog(organizationId, {
+ kind: 'session',
+ userId: session.user.id,
+ sessionId: session.session.id,
+ })
+ return (
+
+
+
+ )
+ }
+ if (!isPlatformSettingsSection(resolved.section)) notFound()
+ return
}
if (resolved.section === 'admin' || resolved.section === 'mothership') {
if (!(await isPlatformAdmin(session.user.id))) notFound()
}
+ if (!isPlatformSettingsSection(resolved.section)) notFound()
/**
* No Suspense boundary: a fallback mounted with the section puts React's 300ms reveal floor
* under every switch (see the workspace section layout).
*/
- const content = (
-
-
-
- )
+ const content =
if (resolved.section === 'general') {
const queryClient = getQueryClient()
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
deleted file mode 100644
index 289f9adab2e..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-'use client'
-
-import dynamic from 'next/dynamic'
-import {
- getOrganizationSettingsHref,
- ORGANIZATION_SETTINGS_ITEMS,
- type OrganizationSettingsSection,
-} from '@/components/settings/navigation'
-import { SettingsSectionProvider } from '@/components/settings/settings-panel'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-
-const KnowledgeGraphs = dynamic(() =>
- import('@/app/o/[organizationId]/settings/components/knowledge-graphs').then(
- (m) => m.KnowledgeGraphs
- )
-)
-
-const OrganizationRecentlyDeleted = dynamic(() =>
- import('@/app/o/[organizationId]/settings/components/organization-recently-deleted').then(
- (m) => m.OrganizationRecentlyDeleted
- )
-)
-
-const OrganizationIntegrationsSettings = dynamic(() =>
- import(
- '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings'
- ).then((m) => m.OrganizationIntegrationsSettings)
-)
-const OrganizationSearchMcp = dynamic(() =>
- import('@/app/o/[organizationId]/settings/components/organization-search-mcp').then(
- (m) => m.OrganizationSearchMcp
- )
-)
-const OrganizationConnectedAccounts = dynamic(() =>
- import('@/ee/credential-groups/components/organization-connected-accounts').then(
- (m) => m.OrganizationConnectedAccounts
- )
-)
-const OrganizationSearchSlack = dynamic(() =>
- import('@/app/o/[organizationId]/settings/components/organization-search-slack').then(
- (m) => m.OrganizationSearchSlack
- )
-)
-
-const TeamManagement = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then(
- (m) => m.TeamManagement
- )
-)
-const Billing = dynamic(() =>
- import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then((m) => m.Billing)
-)
-const AccessControl = dynamic(() =>
- import('@/ee/access-control/components/access-control').then((m) => m.AccessControl)
-)
-const AccessRequestsSettings = dynamic(() =>
- import('@/ee/access-requests/components/access-requests-settings').then(
- (m) => m.AccessRequestsSettings
- )
-)
-const AuditLogs = dynamic(() =>
- import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs)
-)
-const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO))
-const DataRetentionSettings = dynamic(() =>
- import('@/ee/data-retention/components/data-retention-settings').then(
- (m) => m.DataRetentionSettings
- )
-)
-const DataDrainsSettings = dynamic(() =>
- import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings)
-)
-const OrganizationSecuritySettings = dynamic(() =>
- import('@/components/settings/organization-security').then((m) => m.OrganizationSecuritySettings)
-)
-const UsageMonitoring = dynamic(() =>
- import('@/ee/organization-usage/components/usage-monitoring').then((m) => m.UsageMonitoring)
-)
-const WhitelabelingSettings = dynamic(() =>
- import('@/ee/whitelabeling/components/whitelabeling-settings').then(
- (m) => m.WhitelabelingSettings
- )
-)
-
-interface OrganizationSettingsProps {
- section: OrganizationSettingsSection
-}
-
-export function OrganizationSettings({ section }: OrganizationSettingsProps) {
- const { organization, viewer } = useOrganizationContext()
- const organizationId = organization.id
- const meta = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === section)
-
- return (
-
- {section === 'recently-deleted' && (
-
- )}
- {section === 'knowledge-graphs' && }
- {section === 'integrations' && }
- {section === 'connected-accounts' && (
-
- )}
- {section === 'search-mcp' && }
- {section === 'search-slack' && }
- {section === 'members' && (
-
- )}
- {section === 'billing' && }
- {section === 'access-control' && (
-
- )}
- {section === 'requests' && (
-
- )}
- {section === 'audit-logs' && }
- {section === 'usage' && (
-
- )}
- {section === 'sso' && }
- {section === 'security' && }
- {section === 'data-retention' && }
- {section === 'data-drains' && }
- {section === 'whitelabeling' && }
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx
deleted file mode 100644
index 2ec2f6c5a3e..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-'use client'
-
-import { type ReactNode, useState } from 'react'
-import {
- ChipInput,
- ChipModal,
- ChipModalBody,
- ChipModalField,
- ChipModalFooter,
- ChipModalHeader,
-} from '@sim/emcn'
-import { Search } from '@sim/emcn/icons'
-import {
- GENERIC_SECRETS_SOURCE_TYPE,
- GenericSecretSourceIcon,
-} from '@/app/o/[organizationId]/settings/components/integrations/generic-secret-source'
-import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
-import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import {
- RESOURCE_LIST_STACK,
- SettingsResourceRow,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import type { ConnectorMeta } from '@/connectors/types'
-
-interface AddOrganizationSourceModalProps {
- sources: {
- type: string
- meta: Pick & { auth?: ConnectorMeta['auth'] }
- access: { admin: boolean; members: boolean }
- availabilityStatus?: 'loading' | 'error'
- }[]
- pending: boolean
- ready: boolean
- feedback: ReactNode
- onClose: () => void
- onSelect: (type: string, accessMode: 'admin' | 'members') => void
- descriptions?: Readonly>
- compact?: boolean
-}
-
-export function AddOrganizationSourceModal({
- sources,
- pending,
- ready,
- feedback,
- onClose,
- onSelect,
- descriptions,
- compact = false,
-}: AddOrganizationSourceModalProps) {
- const [search, setSearch] = useState('')
- const query = search.trim().toLowerCase()
- const visible = sources.filter(({ meta }) => meta.name.toLowerCase().includes(query))
- const list = (
-
- {visible.map(({ type, meta, access, availabilityStatus }) => {
- const available = access.admin || access.members
- const sourceReady = ready && !availabilityStatus
- return (
-
- ) : (
-
- )
- }
- title={meta.name}
- description={
- availabilityStatus === 'error'
- ? 'Could not check availability'
- : !sourceReady
- ? 'Checking availability'
- : !available
- ? 'Unavailable in this deployment'
- : (descriptions?.[type] ??
- (access.admin
- ? meta.auth?.mode === 'apiKey'
- ? 'Connect an API token'
- : meta.auth?.mode === 'oauth' &&
- meta.auth.adminCredentialType === 'service_account'
- ? 'Connect a service account'
- : 'Connect an admin account'
- : type === 'slack'
- ? 'Set up your Slack app'
- : 'Connect member accounts'))
- }
- disabled={pending || !sourceReady || !available}
- onClick={() => onSelect(type, access.admin ? 'admin' : 'members')}
- clickLabel={`Set up ${meta.name}`}
- navigable={sourceReady && available}
- />
- )
- })}
- {visible.length === 0 && (
- No matching sources
- )}
-
- )
-
- return (
- !open && onClose()}
- srTitle='Add source'
- >
- Add source
-
- {!compact && (
-
- setSearch(event.target.value)}
- disabled={pending}
- />
-
- )}
- {compact ? (
-
- {feedback}
- {list}
-
- ) : (
-
- {feedback}
- {list}
-
- )}
-
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/generic-secret-source.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/generic-secret-source.tsx
deleted file mode 100644
index f92ffa52149..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/integrations/generic-secret-source.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import {
- Chip,
- ChipConfirmModal,
- ChipLink,
- ChipModal,
- ChipModalBody,
- ChipModalError,
- ChipModalField,
- ChipModalFooter,
- ChipModalHeader,
- ChipSwitch,
- toast,
-} from '@sim/emcn'
-import { KeySquare } from '@sim/emcn/icons'
-import { useRouter } from 'next/navigation'
-import { IdentityTile } from '@/components/identity-tile/identity-tile'
-import type { GenericSecretSource } from '@/lib/api/contracts/organization-secrets'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import type { SecretSourceMode } from '@/lib/organization-secrets/validation'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
-import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import {
- useConfigureOrganizationSecretSource,
- useRemoveOrganizationSecretSource,
-} from '@/hooks/queries/organization-secrets'
-
-export const GENERIC_SECRETS_SOURCE_TYPE = 'generic-secrets'
-export const GENERIC_SECRETS_META = { name: 'Generic Secrets', icon: KeySquare }
-
-/** Generic secrets belong to the organization rather than an external provider. */
-export function GenericSecretSourceIcon() {
- const { organization } = useOrganizationContext()
- return (
-
- )
-}
-
-interface GenericSecretSourceModalProps {
- organizationId: string
- source: GenericSecretSource | null
- onClose: () => void
-}
-
-export function GenericSecretSourceModal({
- organizationId,
- source,
- onClose,
-}: GenericSecretSourceModalProps) {
- const [mode, setMode] = useState(source?.mode ?? 'organization')
- const update = useConfigureOrganizationSecretSource(organizationId)
- const router = useRouter()
- const close = () => {
- if (!update.isPending) onClose()
- }
- return (
- {
- if (!open) close()
- }}
- srTitle='Generic Secrets'
- >
- Generic Secrets
-
-
-
-
- {update.error?.message}
-
-
- update.mutate(
- { sourceId: source?.id ?? null, mode },
- {
- onSuccess: () => {
- onClose()
- if (mode === 'organization')
- router.push(organizationRoutes(organizationId).organizationSecrets)
- },
- }
- ),
- }}
- />
-
- )
-}
-
-interface GenericSecretSourceRowProps {
- organizationId: string
- source: GenericSecretSource
- admin?: boolean
-}
-
-export function GenericSecretSourceRow({
- organizationId,
- source,
- admin = false,
-}: GenericSecretSourceRowProps) {
- const [editing, setEditing] = useState(false)
- const [removing, setRemoving] = useState(false)
- const remove = useRemoveOrganizationSecretSource(organizationId)
- const routes = organizationRoutes(organizationId)
- return (
- <>
- }
- title='Generic Secrets'
- description={source.mode === 'organization' ? 'Organization' : 'Member'}
- trailing={
-
- {admin ? (
- <>
- {source.mode === 'organization' && (
- Secrets
- )}
- setEditing(true)}>Configure
- setRemoving(true),
- },
- ]}
- />
- >
- ) : source.mode === 'member' ? (
- Manage secrets
- ) : (
- Organization managed
- )}
-
- }
- />
- {editing && (
- setEditing(false)}
- />
- )}
- {
- if (!remove.isPending) setRemoving(open)
- }}
- title='Remove Generic Secrets source?'
- text='This deletes its organization and member secrets.'
- confirm={{
- label: 'Remove source',
- variant: 'destructive',
- pending: remove.isPending,
- onClick: () =>
- remove.mutate(source.id, {
- onSuccess: () => setRemoving(false),
- onError: (error) => toast.error(error.message),
- }),
- }}
- />
- >
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-policy-modal.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-policy-modal.tsx
deleted file mode 100644
index 8c2aa0ea7c3..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-policy-modal.tsx
+++ /dev/null
@@ -1,282 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import {
- Chip,
- ChipLink,
- ChipModal,
- ChipModalBody,
- ChipModalError,
- ChipModalField,
- ChipModalFooter,
- ChipModalHeader,
- ChipSelect,
- ChipSwitch,
- toast,
-} from '@sim/emcn'
-import { Plus } from '@sim/emcn/icons'
-import { getErrorMessage } from '@sim/utils/errors'
-import { useRouter } from 'next/navigation'
-import type { SearchIntegrationApproval } from '@/lib/api/contracts/knowledge/search-integrations'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { connectorDisplayName } from '@/lib/sim-search/connectors'
-import {
- defaultLiveSearchPolicy,
- LIVE_SEARCH_SERVICE_PROVIDERS,
- type LiveSearchPolicy,
- normalizeLiveSearchPolicy,
-} from '@/lib/sim-search/live/policy-schema'
-import { searchSetupParam, serializeSearchSetup } from '@/lib/sim-search/params'
-import { SettingsListPagination } from '@/app/workspace/[workspaceId]/settings/components/settings-list-pagination'
-import { useSearchSources } from '@/hooks/queries/kb/connectors'
-import { useUpdateSearchIntegration } from '@/hooks/queries/search-integrations'
-
-interface LiveSearchPolicyModalProps {
- organizationId: string
- integration: SearchIntegrationApproval
- onClose: () => void
-}
-
-export function LiveSearchPolicyModal({
- organizationId,
- integration,
- onClose,
-}: LiveSearchPolicyModalProps) {
- const update = useUpdateSearchIntegration()
- const router = useRouter()
- const provider = integration.connectorType
- const [accessMode, setAccessMode] = useState>(
- provider === 'gitlab' ? 'service_account' : (integration.policy?.accessMode ?? 'member')
- )
- const [sourceId, setSourceId] = useState(integration.policy?.sourceId ?? '')
- const [error, setError] = useState('')
- const supportsServiceAccount = LIVE_SEARCH_SERVICE_PROVIDERS.includes(provider)
- const needsServiceSetup =
- accessMode === 'service_account' &&
- (provider === 'github'
- ? !integration.approved || integration.policy?.accessMode !== 'service_account'
- : provider === 'gitlab'
- ? !integration.approved
- : !sourceId)
- const close = () => {
- if (!update.isPending) onClose()
- }
- const save = (openSetup = needsServiceSetup) => {
- if (update.isPending) return
- try {
- const policy = normalizeLiveSearchPolicy(provider, {
- ...defaultLiveSearchPolicy(),
- accessMode,
- ...(accessMode === 'service_account' &&
- provider !== 'gitlab' &&
- provider !== 'github' &&
- sourceId
- ? { sourceId }
- : {}),
- })
- setError('')
- update.mutate(
- { organizationId, connectorType: provider, approved: true, policy },
- {
- onSuccess: () => {
- onClose()
- if (openSetup)
- router.push(
- serializeSearchSetup(organizationRoutes(organizationId).searchProvider(provider), {
- addConnector: searchSetupParam.parser.parse(provider),
- 'source-access': null,
- })
- )
- else toast.success('Search settings saved')
- },
- }
- )
- } catch (error) {
- setError(getErrorMessage(error, 'Choose a service account connection and try again.'))
- }
- }
- return (
- {
- if (!open) close()
- }}
- srTitle={`${connectorDisplayName(provider)} search settings`}
- >
- {connectorDisplayName(provider)}
-
-
-
- {provider === 'gitlab' ? (
- Service account
- ) : supportsServiceAccount ? (
- {
- setAccessMode(value)
- setError('')
- }}
- options={[
- { value: 'member', label: 'Member accounts' },
- {
- value: 'service_account',
- label: provider === 'github' ? 'GitHub App' : 'Service account',
- },
- ]}
- />
- ) : (
- Member accounts
- )}
-
- {accessMode === 'service_account' &&
- (provider === 'gitlab' || provider === 'github' ? (
-
- {integration.approved ? (
-
- {provider === 'github'
- ? 'Manage GitHub repositories'
- : 'Manage GitLab projects'}
-
- ) : null}
-
- ) : (
- save(true)}
- disabled={update.isPending}
- />
- ))}
- {error || update.error?.message}
-
-
- save(),
- disabled: update.isPending,
- }}
- />
-
- )
-}
-
-interface ServiceAccountSourceProps {
- organizationId: string
- provider: string
- sourceId: string
- onChange: (sourceId: string) => void
- canManage: boolean
- onAdd: () => void
- disabled: boolean
-}
-
-function ServiceAccountSource({
- organizationId,
- provider,
- sourceId,
- onChange,
- canManage,
- onAdd,
- disabled,
-}: ServiceAccountSourceProps) {
- const sources = useSearchSources(
- { kind: 'organization', organizationId },
- { connectorType: provider }
- )
- const options = (sources.data ?? [])
- .filter((source) => source.accessMode === 'admin')
- .map((source) => ({
- value: source.connectorId,
- label: `${source.sourceDescription || connectorDisplayName(provider)}${source.enabled ? '' : ' · Paused'}`,
- disabled: !source.enabled || source.availability !== 'available',
- }))
- if (
- !sourceId &&
- !sources.isPending &&
- !sources.isError &&
- !options.length &&
- !sources.hasNextPage
- )
- return null
- return (
-
-
- {(sourceId || options.length > 0 || sources.isPending) && (
-
option.value === sourceId)
- ? 'Configured connection'
- : undefined
- }
- disabled={disabled || sources.isPending || sources.isError || !options.length}
- searchable
- fullWidth
- dropdownWidth='trigger'
- align='start'
- />
- )}
- {sources.isError && !sources.isFetchNextPageError && (
- <>
- {sources.error?.message}
- void sources.refetch()}>
- Retry
-
- >
- )}
-
- {sourceId && (
-
- {canManage && (
-
- Edit connection
-
- )}
-
- Add connection
-
-
- )}
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx
deleted file mode 100644
index c09a9c837ce..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.tsx
+++ /dev/null
@@ -1,401 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { Chip, ChipConfirmModal, ChipLink, toast } from '@sim/emcn'
-import { Plus } from '@sim/emcn/icons'
-import { useRouter } from 'next/navigation'
-import { useQueryState } from 'nuqs'
-import { SettingsPanel } from '@/components/settings/settings-panel'
-import type { SearchIntegrationApproval } from '@/lib/api/contracts/knowledge/search-integrations'
-import { hasSlackSearchUserScopes } from '@/lib/credential-groups/slack-managed-user-scopes'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import {
- defaultLiveSearchPolicy,
- LIVE_SEARCH_SCOPE_FIELDS,
- LIVE_SEARCH_SERVICE_PROVIDERS,
-} from '@/lib/sim-search/live/policy-schema'
-import {
- getLiveSearchAccessAvailability,
- LIVE_SEARCH_SOURCE_TYPES,
- liveSearchMcpConnector,
- liveSearchMemberAccountProvider,
-} from '@/lib/sim-search/live/source-catalog'
-import { AssistantAccountIntegrations } from '@/app/o/[organizationId]/integrations/assistant-account-integrations'
-import { assistantAccountOptions } from '@/app/o/[organizationId]/integrations/utils'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { AddOrganizationSourceModal } from '@/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal'
-import {
- GENERIC_SECRETS_META,
- GENERIC_SECRETS_SOURCE_TYPE,
- GenericSecretSourceModal,
- GenericSecretSourceRow,
-} from '@/app/o/[organizationId]/settings/components/integrations/generic-secret-source'
-import { LiveSearchPolicyModal } from '@/app/o/[organizationId]/settings/components/integrations/live-search-policy-modal'
-import { connectedAccountsParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params'
-import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup'
-import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
-import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
-import {
- SettingsEmptyState,
- SettingsQueryErrorState,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import {
- RESOURCE_LIST_STACK,
- SettingsResourceRow,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
-import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts'
-import { useOrganizationSecretSource } from '@/hooks/queries/organization-secrets'
-import {
- useSearchIntegrations,
- useUpdateSearchIntegration,
-} from '@/hooks/queries/search-integrations'
-import { usePermissionConfig } from '@/hooks/use-permission-config'
-
-export function LiveSearchSettings() {
- const { organization, viewer, searchAccess } = useOrganizationContext()
- const policies = useSearchIntegrations(organization.id)
- const secrets = useOrganizationSecretSource(organization.id)
- const accounts = useOrganizationAccounts(viewer.isAdmin ? organization.id : undefined)
- const update = useUpdateSearchIntegration()
- const availability = usePermissionConfig()
- const router = useRouter()
- const [search, setSearch] = useSettingsSearch()
- const [editing, setEditing] = useState(null)
- const [removing, setRemoving] = useState(null)
- const [adding, setAdding] = useState(false)
- const [addingSecrets, setAddingSecrets] = useState(false)
- const [, setConnectedAccounts] = useQueryState(
- connectedAccountsParam.key,
- connectedAccountsParam.parser
- )
- if (!viewer.isAdmin) return null
-
- const added =
- policies.data?.filter((row) => row.approved && LIVE_SEARCH_SCOPE_FIELDS[row.connectorType]) ??
- []
- const visible = added.filter((integration) =>
- LIVE_SEARCH_SOURCE_TYPES.some(
- ([type, meta]) =>
- type === integration.connectorType && meta.name.toLowerCase().includes(search.toLowerCase())
- )
- )
- const secretSource = secrets.data?.source
- const showSecrets =
- secretSource && GENERIC_SECRETS_META.name.toLowerCase().includes(search.toLowerCase())
- const availableToAdd = [
- ...LIVE_SEARCH_SOURCE_TYPES.filter(
- ([type]) =>
- (type !== 'zoom' ||
- policies.data?.some((row) => row.connectorType === type && row.available !== false)) &&
- LIVE_SEARCH_SCOPE_FIELDS[type] &&
- !added.some((row) => row.connectorType === type)
- ).map(([type, meta]) => ({
- type,
- meta,
- availabilityStatus:
- (type !== 'hubspot' && type !== 'zoom') || accounts.isSuccess
- ? undefined
- : accounts.isError
- ? ('error' as const)
- : ('loading' as const),
- access: getLiveSearchAccessAvailability(type, availability.integrationAvailability, {
- memberAccessAvailable: searchAccess.memberScoped,
- availableMcpConnectors: accounts.data?.availableMcpConnectors,
- mirroredAccessAvailable: searchAccess.sourceMirrored,
- oauthServiceAvailability: availability.oauthServiceAvailability,
- isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady,
- }),
- })),
- ...(!secretSource
- ? [
- {
- type: GENERIC_SECRETS_SOURCE_TYPE,
- meta: GENERIC_SECRETS_META,
- access: { admin: true, members: true },
- },
- ]
- : []),
- ]
- const routes = organizationRoutes(organization.id)
-
- return (
- setAdding(true),
- },
- ]}
- search={{ value: search, onChange: setSearch, placeholder: 'Search sources' }}
- >
- {accounts.data && (
-
- )}
- {policies.error || secrets.error ? (
- {
- void policies.refetch()
- void secrets.refetch()
- }}
- variant='inline'
- />
- ) : !policies.data || !secrets.data ? (
- Loading sources
- ) : added.length === 0 &&
- !secretSource &&
- !search &&
- !(accounts.data && assistantAccountOptions(accounts.data, search).visible.length) ? (
- No sources yet. Add a source to get started.
- ) : (
- <>
-
- {showSecrets && (
-
- )}
- {visible.map((integration) => {
- const [type, meta] = LIVE_SEARCH_SOURCE_TYPES.find(
- ([sourceType]) => sourceType === integration.connectorType
- )!
- const serviceAccount =
- type === 'gitlab' || integration.policy?.accessMode === 'service_account'
- const memberProvider = liveSearchMemberAccountProvider(type)
- const mcpProvider = liveSearchMcpConnector(type)
- const group = accounts.data?.credentialGroup
- const slackOption = group?.options.find((option) => option.provider === 'slack')
- const needsSlackSetup =
- type === 'slack' &&
- accounts.data &&
- (group?.status !== 'active' ||
- slackOption?.status !== 'active' ||
- slackOption.configurationStatus !== 'ready' ||
- !hasSlackSearchUserScopes(slackOption.requiredScopes))
- const needsMemberSetup =
- integration.available !== false &&
- accounts.data &&
- (memberProvider || mcpProvider) &&
- (group?.status !== 'active' ||
- (mcpProvider
- ? !group.mcpServers.some(
- (server) => server.managedConnectorId === mcpProvider && server.enabled
- )
- : !group.options.some(
- (option) =>
- option.provider === memberProvider &&
- option.status === 'active' &&
- option.configurationStatus === 'ready'
- )))
- const scope = serviceAccount
- ? type === 'gitlab'
- ? 'Projects and permissions'
- : type === 'github'
- ? 'GitHub App repositories'
- : integration.policy?.sourceId
- ? 'Service account'
- : 'Service account · Add a connection'
- : 'Member accounts'
- return (
-
}
- title={meta.name}
- description={
- integration.available === false
- ? 'Currently unavailable'
- : needsSlackSetup
- ? 'Member accounts · Verify Search permissions'
- : scope
- }
- trailing={
-
- {serviceAccount && (
-
- {type === 'gitlab'
- ? 'Projects'
- : type === 'github'
- ? 'Repositories'
- : 'Connections'}
-
- )}
- {type === 'slack' && (
- void setConnectedAccounts('slack')}>
- {needsSlackSetup ? 'Verify permissions' : 'Slack app'}
-
- )}
- {needsMemberSetup && (
-
- update.mutate(
- {
- organizationId: organization.id,
- connectorType: type,
- approved: true,
- policy: integration.policy ?? defaultLiveSearchPolicy(type),
- },
- {
- onSuccess: () => toast.success('Source settings saved'),
- onError: (error) => toast.error(error.message),
- }
- )
- }
- >
- Set up accounts
-
- )}
- {LIVE_SEARCH_SERVICE_PROVIDERS.includes(type) && type !== 'gitlab' && (
- setEditing(integration)}>Configure
- )}
- setRemoving(type),
- },
- ]}
- />
-
- }
- />
- )
- })}
-
- {search &&
- visible.length === 0 &&
- !showSecrets &&
- !(accounts.data && assistantAccountOptions(accounts.data, search).visible.length) && (
- No matching sources
- )}
- >
- )}
- {editing && (
- setEditing(null)}
- />
- )}
-
- {addingSecrets && (
- setAddingSecrets(false)}
- />
- )}
- {
- if (!open && !update.isPending) setRemoving(null)
- }}
- title={`Remove ${removing ? LIVE_SEARCH_SOURCE_TYPES.find(([type]) => type === removing)?.[1].name : ''} source?`}
- text='Members will no longer be able to search this source.'
- confirm={{
- label: 'Remove source',
- variant: 'destructive',
- pending: update.isPending,
- onClick: () => {
- if (!removing) return
- update.mutate(
- { organizationId: organization.id, connectorType: removing, approved: false },
- {
- onSuccess: () => setRemoving(null),
- onError: (error) => toast.error(error.message),
- }
- )
- },
- }}
- />
- {adding && (
- [
- type,
- type === GENERIC_SECRETS_SOURCE_TYPE
- ? 'Organization or member secrets'
- : type === 'github'
- ? 'GitHub App repositories'
- : type === 'gitlab'
- ? 'Projects and permissions'
- : access.admin
- ? 'Service account or member accounts'
- : 'Member accounts',
- ])
- )}
- pending={update.isPending}
- ready={availability.isIntegrationAvailabilityReady}
- feedback={
- accounts.error ? (
- void accounts.refetch()}
- variant='inline'
- />
- ) : null
- }
- onClose={() => setAdding(false)}
- onSelect={(type, mode) => {
- if (type === GENERIC_SECRETS_SOURCE_TYPE) {
- setAdding(false)
- setAddingSecrets(true)
- return
- }
- if (type === 'gitlab' || !LIVE_SEARCH_SERVICE_PROVIDERS.includes(type)) {
- update.mutate(
- {
- organizationId: organization.id,
- connectorType: type,
- approved: true,
- policy: defaultLiveSearchPolicy(type),
- },
- {
- onSuccess: () => {
- setAdding(false)
- if (type === 'gitlab') router.push(routes.searchProvider(type))
- if (type === 'slack') void setConnectedAccounts('slack')
- },
- onError: (error) => toast.error(error.message),
- }
- )
- return
- }
- setAdding(false)
- setEditing({
- connectorType: type,
- approved: false,
- policy: {
- ...defaultLiveSearchPolicy(type),
- accessMode: mode === 'admin' || type === 'github' ? 'service_account' : 'member',
- },
- })
- }}
- />
- )}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx
deleted file mode 100644
index 035c95cb41c..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-'use client'
-
-import { LiveSearchSettings } from '@/app/o/[organizationId]/settings/components/integrations/live-search-settings'
-
-export function OrganizationIntegrationsSettings() {
- return
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.tsx
deleted file mode 100644
index c0fd9c270cf..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.tsx
+++ /dev/null
@@ -1,419 +0,0 @@
-'use client'
-
-import { useEffect, useRef, useState } from 'react'
-import {
- Chip,
- ChipInput,
- ChipModal,
- ChipModalBody,
- ChipModalError,
- ChipModalField,
- ChipModalHeader,
- toast,
-} from '@sim/emcn'
-import { Search } from '@sim/emcn/icons'
-import dynamic from 'next/dynamic'
-import { useRouter } from 'next/navigation'
-import { useQueryState, useQueryStates } from 'nuqs'
-import { useSession } from '@/lib/auth/client'
-import {
- type ResourceScope,
- resourceScopeFields,
- resourceScopeKey,
-} from '@/lib/core/resource-scope'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { getSearchConnectionLabels } from '@/lib/sim-search/connection-labels'
-import { getConnectorAccessAvailability, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors'
-import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema'
-import {
- managedSourceParam,
- searchSetupAccessParam,
- searchSetupParam,
-} from '@/lib/sim-search/params'
-import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
-import {
- SettingsEmptyState,
- SettingsQueryErrorState,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import {
- RESOURCE_LIST_STACK,
- SettingsResourceRow,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
-import { usePrepareSearchSource, useSearchIndex } from '@/hooks/queries/kb/connectors'
-import { useUpdateSearchIntegration } from '@/hooks/queries/search-integrations'
-import { usePermissionConfig } from '@/hooks/use-permission-config'
-
-const AddConnectorModal = dynamic(
- () =>
- import('@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal').then(
- (module) => module.AddConnectorModal
- ),
- { ssr: false }
-)
-interface SearchSourceSetupProps {
- scope: Extract
- canAdmin: boolean
- memberAccessAvailable: boolean
- mirroredAccessAvailable: boolean
-}
-
-/** Owns admin setup and existing source management, including bookmarked OAuth return URLs. */
-export function SearchSourceSetup({
- scope,
- canAdmin,
- memberAccessAvailable,
- mirroredAccessAvailable,
-}: SearchSourceSetupProps) {
- const { data: session } = useSession()
- const {
- integrationAvailability,
- oauthServiceAvailability,
- isIntegrationAvailabilityReady,
- isIntegrationAvailabilityFetching,
- isIntegrationAvailabilityLoading,
- integrationAvailabilityError,
- refetchIntegrationAvailability,
- } = usePermissionConfig()
- const [setup, setSetup] = useQueryStates(
- {
- [searchSetupParam.key]: searchSetupParam.parser,
- [searchSetupAccessParam.key]: searchSetupAccessParam.parser,
- },
- { history: 'replace' }
- )
- const selectedType = setup.addConnector
- const setSelectedType = (type: typeof selectedType) =>
- setSetup({ addConnector: type, ...(type === null ? { 'source-access': null } : {}) })
- const [managedSource, setManagedSource] = useQueryState(
- managedSourceParam.key,
- managedSourceParam.parser.withOptions({ history: 'replace' })
- )
- const [search, setSearch] = useState('')
- const attemptedPreparation = useRef(null)
- const router = useRouter()
- const prepare = usePrepareSearchSource()
- const updateSearchIntegration = useUpdateSearchIntegration()
- const { mutate: prepareSource, isPending: preparing } = prepare
- const selectedMeta = selectedType ? CONNECTOR_META_REGISTRY[selectedType] : undefined
- const redirectPersonalSetup = Boolean(
- canAdmin &&
- selectedMeta &&
- setup['source-access'] !== 'members' &&
- !selectedMeta.mirrorsSourceAcls &&
- selectedType !== 'slack' &&
- selectedType !== 'github'
- )
- const redirectManagement = canAdmin && managedSource !== null && selectedType === null
- const { organizationId } = scope
- useEffect(() => {
- if (redirectPersonalSetup && organizationId) {
- router.replace(organizationRoutes(organizationId).integrations)
- }
- }, [redirectPersonalSetup, organizationId, router])
- useEffect(() => {
- if (!redirectManagement || !organizationId || managedSource === null) return
- const routes = organizationRoutes(organizationId)
- if (!managedSource || CONNECTOR_META_REGISTRY[managedSource]) {
- void setManagedSource(null, { history: 'replace', scroll: false })
- } else {
- router.replace(routes.searchSource(managedSource))
- }
- }, [redirectManagement, organizationId, managedSource, router, setManagedSource])
- const open = selectedType !== null || managedSource !== null
- const index = useSearchIndex(scope, {
- enabled: canAdmin && open && !redirectManagement && !redirectPersonalSetup,
- })
- const knowledgeBaseId = index.data?.knowledgeBaseId ?? undefined
-
- const close = () => {
- if (prepare.isPending) return
- if (selectedType !== null) void setSelectedType(null)
- if (managedSource !== null) void setManagedSource(null)
- }
- const failedQuery = index.isError ? index : null
- const initialMode = (type: string) =>
- type === 'github' || type === 'slack' || setup['source-access'] === 'members'
- ? ('members' as const)
- : ('admin' as const)
-
- const selectedAccessMode = selectedType ? initialMode(selectedType) : undefined
- const selectedAvailability = selectedMeta
- ? getConnectorAccessAvailability(selectedMeta, integrationAvailability, {
- memberAccessAvailable,
- mirroredAccessAvailable,
- oauthServiceAvailability,
- isIntegrationAvailabilityReady,
- })
- : undefined
- const selectedAvailable = selectedAvailability
- ? selectedAccessMode === 'admin'
- ? selectedAvailability.admin
- : selectedAvailability.members
- : false
- const canPrepareSelected =
- canAdmin &&
- open &&
- !redirectManagement &&
- !redirectPersonalSetup &&
- !index.isPending &&
- !index.isError &&
- !integrationAvailabilityError &&
- selectedAvailable
- const userId = session?.user?.id
-
- /** A known provider only needs the canonical index prepared, not another selection step. */
- useEffect(() => {
- if (!selectedType || knowledgeBaseId) {
- attemptedPreparation.current = null
- return
- }
- if (!canPrepareSelected || !selectedAccessMode || !userId || preparing) return
- const requestKey = `${userId}:${resourceScopeKey(scope)}:${selectedType}:${selectedAccessMode}`
- if (attemptedPreparation.current === requestKey) return
- attemptedPreparation.current = requestKey
- prepareSource({
- ...resourceScopeFields(scope),
- connectorType: selectedType,
- accessMode: selectedAccessMode,
- })
- }, [
- selectedType,
- knowledgeBaseId,
- canPrepareSelected,
- selectedAccessMode,
- userId,
- preparing,
- scope,
- prepareSource,
- ])
-
- if (!canAdmin || !open || redirectManagement || redirectPersonalSetup) return null
-
- if (
- !failedQuery &&
- knowledgeBaseId &&
- (isIntegrationAvailabilityReady || integrationAvailability.size > 0)
- ) {
- if (selectedType && session?.user?.id) {
- const accessMode = initialMode(selectedType)
- const setupMode = accessMode
- return (
- {
- if (!nextOpen) void setSelectedType(null)
- }}
- knowledgeBaseId={knowledgeBaseId}
- scope={scope}
- isSearchIndex
- initialConnectorType={selectedType}
- initialAccessMode={accessMode}
- lockConnectorType
- lockedAccessMode={setupMode}
- setupDraftKey={`${session.user.id}:${resourceScopeKey(scope)}:${knowledgeBaseId}:${selectedType}:${setupMode}`}
- onConnectorTypeChange={(type) =>
- void setSelectedType(type !== null ? searchSetupParam.parser.parse(type) : null)
- }
- onCreated={async (type, connector) => {
- await setSelectedType(null)
- const destination = organizationRoutes(scope.organizationId).searchSource(connector.id)
- if (accessMode === 'admin' && type !== 'gitlab') {
- updateSearchIntegration.mutate(
- {
- organizationId: scope.organizationId,
- connectorType: type,
- approved: true,
- policy: {
- ...defaultLiveSearchPolicy(type),
- accessMode: 'service_account',
- sourceId: connector.id,
- },
- },
- {
- onSuccess: () => router.push(destination),
- onError: (error) => {
- toast.error(`Source added but search is not ready: ${error.message}`)
- router.push(destination)
- },
- }
- )
- } else {
- router.push(destination)
- }
- }}
- />
- )
- }
- }
-
- if (
- selectedType &&
- !failedQuery &&
- !integrationAvailabilityError &&
- !prepare.error &&
- (!isIntegrationAvailabilityReady || selectedAvailable)
- ) {
- return null
- }
-
- const normalizedSearch = search.trim().toLowerCase()
- const visibleTypes = SEARCH_SOURCE_TYPES.filter(
- ([type, meta]) =>
- (setup['source-access'] === 'members' ||
- meta.mirrorsSourceAcls ||
- type === 'slack' ||
- type === 'github') &&
- `${meta.name} ${meta.description}`.toLowerCase().includes(normalizedSearch)
- )
-
- return (
- {
- if (!nextOpen) close()
- }}
- srTitle='Add source'
- >
-
- {selectedType
- ? getSearchConnectionLabels(selectedType, selectedAccessMode).title
- : 'Add source'}
-
-
- {failedQuery ? (
-
- void failedQuery.refetch()}
- variant='inline'
- />
-
- ) : integrationAvailabilityError ? (
-
- void refetchIntegrationAvailability()}
- variant='inline'
- />
-
- ) : isIntegrationAvailabilityLoading ? (
-
- Loading sources
-
- ) : managedSource ? (
-
-
- {index.isPending ? 'Loading source' : 'This source is no longer available.'}
-
-
- ) : selectedType ? (
-
- {!selectedAvailable ? (
-
- Not available in this {scope.kind}
-
- ) : prepare.error ? (
- {
- if (!canPrepareSelected || !selectedAccessMode || !userId || preparing) return
- prepareSource({
- ...resourceScopeFields(scope),
- connectorType: selectedType,
- accessMode: selectedAccessMode,
- })
- }}
- variant='inline'
- />
- ) : (
- Loading source setup
- )}
-
- ) : (
- <>
-
- setSearch(event.target.value)}
- />
-
-
-
- {visibleTypes.map(([type, meta]) => {
- const { admin: central, members } = getConnectorAccessAvailability(
- meta,
- integrationAvailability,
- {
- memberAccessAvailable,
- mirroredAccessAvailable,
- oauthServiceAvailability,
- isIntegrationAvailabilityReady,
- }
- )
- const available = initialMode(type) === 'members' ? members : central
- return (
- }
- title={meta.name}
- description={
- !available
- ? `Not available in this ${scope.kind}`
- : central
- ? meta.adminSetupHint
- : undefined
- }
- disabled={!available}
- trailing={
- available ? (
- {
- if (knowledgeBaseId)
- void setSelectedType(searchSetupParam.parser.parse(type))
- else
- prepare.mutate(
- {
- ...resourceScopeFields(scope),
- connectorType: type,
- accessMode: initialMode(type),
- },
- {
- onSuccess: () =>
- void setSelectedType(searchSetupParam.parser.parse(type)),
- }
- )
- }}
- >
- Set up
-
- ) : undefined
- }
- />
- )
- })}
- {visibleTypes.length === 0 && (
- No matching sources.
- )}
-
-
- {prepare.error?.message}
- >
- )}
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-removal.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-removal.tsx
deleted file mode 100644
index 10dac1eb1bb..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-removal.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-'use client'
-
-import { ChipConfirmModal, ChipModalError } from '@sim/emcn'
-import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts'
-import { useUpdateOrganizationAccounts } from '@/hooks/queries/organization-accounts'
-
-interface OrganizationSlackAccountRemovalProps {
- organizationId: string
- group: NonNullable
- onClose: () => void
- onRemoved: () => void
-}
-
-export function OrganizationSlackAccountRemoval({
- organizationId,
- group,
- onClose,
- onRemoved,
-}: OrganizationSlackAccountRemovalProps) {
- const update = useUpdateOrganizationAccounts()
- return (
- {
- if (!open && !update.isPending) onClose()
- }}
- title='Remove Slack app setup?'
- text='This disconnects your organization’s Slack accounts and clears their saved app configuration. Remove any connections using these accounts first.'
- confirm={{
- label: 'Remove',
- variant: 'destructive',
- pending: update.isPending,
- onClick: () =>
- update.mutate(
- {
- organizationId,
- groupId: group.id,
- update: {
- options: group.options
- .filter((option) => option.provider !== 'slack')
- .map(({ id, provider, label, required }) => ({ id, provider, label, required })),
- },
- },
- { onSuccess: onRemoved }
- ),
- }}
- >
- {update.error?.message}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx
deleted file mode 100644
index b8c47c3d361..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-'use client'
-
-import { useEffect } from 'react'
-import {
- ChipModal,
- ChipModalBody,
- ChipModalField,
- ChipModalFooter,
- ChipModalHeader,
-} from '@sim/emcn'
-import { useQueryState } from 'nuqs'
-import { searchSetupParam, searchSetupReturnParam } from '@/lib/sim-search/params'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { connectedAccountsParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params'
-import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal'
-import {
- useEnsureOrganizationAccounts,
- useOrganizationAccounts,
-} from '@/hooks/queries/organization-accounts'
-
-/** Slack's provider verification returns to the source form that started setup. */
-export function OrganizationSlackAccountSetup() {
- const { organization, viewer } = useOrganizationContext()
- const [provider, setProvider] = useQueryState(
- connectedAccountsParam.key,
- connectedAccountsParam.parser.withOptions({ history: 'replace' })
- )
- const [returnSource, setReturnSource] = useQueryState(
- searchSetupReturnParam.key,
- searchSetupReturnParam.parser
- )
- const [, setSelectedType] = useQueryState(searchSetupParam.key, searchSetupParam.parser)
- const open = provider === 'slack' && viewer.isAdmin
- const accounts = useOrganizationAccounts(open ? organization.id : undefined)
- const {
- mutate: ensureAccounts,
- data: preparedAccounts,
- error: setupError,
- isIdle: setupIdle,
- isPending: setupPending,
- } = useEnsureOrganizationAccounts()
- const prepared = preparedAccounts?.credentialGroup
- const group =
- accounts.data?.credentialGroup ??
- (prepared?.organizationId === organization.id ? prepared : undefined)
- const needsSetup = open && accounts.isSuccess && !group
- useEffect(() => {
- if (needsSetup && setupIdle) ensureAccounts({ organizationId: organization.id })
- }, [ensureAccounts, needsSetup, setupIdle, organization.id])
-
- const close = () => {
- void setProvider(null)
- void setReturnSource(null, { history: 'replace' })
- if (returnSource)
- void setSelectedType(returnSource === 'search' ? null : returnSource, { history: 'replace' })
- }
- if (!open) return null
- if (group)
- return (
- option.provider === 'slack')?.slackBotCredentialId ??
- undefined
- }
- initialRequiredScopes={
- group.options.find((option) => option.provider === 'slack')?.requiredScopes
- }
- onOpenChange={(next) => {
- if (!next) close()
- }}
- />
- )
- const error = accounts.error ?? setupError
- if (!error) return null
- return (
- {
- if (!next) close()
- }}
- srTitle='Set up Slack app'
- >
- Set up Slack app
-
-
-
- accounts.error
- ? void accounts.refetch()
- : ensureAccounts({ organizationId: organization.id })
- }
- variant='inline'
- />
-
-
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/use-search-source-setup.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/use-search-source-setup.ts
new file mode 100644
index 00000000000..57d16e7cfe8
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/use-search-source-setup.ts
@@ -0,0 +1,272 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { toast } from '@sim/emcn'
+import { useRouter } from 'next/navigation'
+import { useQueryState, useQueryStates } from 'nuqs'
+import type { ConnectorData } from '@/lib/api/contracts/knowledge/connectors'
+import { useSession } from '@/lib/auth/client'
+import {
+ type ResourceScope,
+ resourceScopeFields,
+ resourceScopeKey,
+} from '@/lib/core/resource-scope'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { getConnectorAccessAvailability, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors'
+import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema'
+import {
+ managedSourceParam,
+ searchSetupAccessParam,
+ searchSetupParam,
+} from '@/lib/sim-search/params'
+import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
+import { usePrepareSearchSource, useSearchIndex } from '@/hooks/queries/kb/connectors'
+import { useUpdateSearchIntegration } from '@/hooks/queries/search-integrations'
+import { usePermissionConfig } from '@/hooks/use-permission-config'
+
+export interface SearchSourceSetupOptions {
+ scope: Extract
+ canAdmin: boolean
+ memberAccessAvailable: boolean
+ mirroredAccessAvailable: boolean
+}
+
+/** Owns admin setup and existing source management, including bookmarked OAuth return URLs. */
+export function useSearchSourceSetup({
+ scope,
+ canAdmin,
+ memberAccessAvailable,
+ mirroredAccessAvailable,
+}: SearchSourceSetupOptions) {
+ const { data: session } = useSession()
+ const {
+ integrationAvailability,
+ oauthServiceAvailability,
+ isIntegrationAvailabilityReady,
+ isIntegrationAvailabilityFetching,
+ isIntegrationAvailabilityLoading,
+ integrationAvailabilityError,
+ refetchIntegrationAvailability,
+ } = usePermissionConfig()
+ const [setup, setSetup] = useQueryStates(
+ {
+ [searchSetupParam.key]: searchSetupParam.parser,
+ [searchSetupAccessParam.key]: searchSetupAccessParam.parser,
+ },
+ { history: 'replace' }
+ )
+ const selectedType = setup.addConnector
+ const setSelectedType = (type: typeof selectedType) =>
+ setSetup({ addConnector: type, ...(type === null ? { 'source-access': null } : {}) })
+ const [managedSource, setManagedSource] = useQueryState(
+ managedSourceParam.key,
+ managedSourceParam.parser.withOptions({ history: 'replace' })
+ )
+ const [search, setSearch] = useState('')
+ const attemptedPreparation = useRef(null)
+ const router = useRouter()
+ const prepare = usePrepareSearchSource()
+ const updateSearchIntegration = useUpdateSearchIntegration()
+ const { mutate: prepareSource, isPending: preparing } = prepare
+ const selectedMeta = selectedType ? CONNECTOR_META_REGISTRY[selectedType] : undefined
+ const redirectPersonalSetup = Boolean(
+ canAdmin &&
+ selectedMeta &&
+ setup['source-access'] !== 'members' &&
+ !selectedMeta.mirrorsSourceAcls &&
+ selectedType !== 'slack' &&
+ selectedType !== 'github'
+ )
+ const redirectManagement = canAdmin && managedSource !== null && selectedType === null
+ const { organizationId } = scope
+ useEffect(() => {
+ if (redirectPersonalSetup && organizationId) {
+ router.replace(organizationRoutes(organizationId).integrations)
+ }
+ }, [redirectPersonalSetup, organizationId, router])
+ useEffect(() => {
+ if (!redirectManagement || !organizationId || managedSource === null) return
+ const routes = organizationRoutes(organizationId)
+ if (!managedSource || CONNECTOR_META_REGISTRY[managedSource]) {
+ void setManagedSource(null, { history: 'replace', scroll: false })
+ } else {
+ router.replace(routes.searchSource(managedSource))
+ }
+ }, [redirectManagement, organizationId, managedSource, router, setManagedSource])
+ const open = selectedType !== null || managedSource !== null
+ const index = useSearchIndex(scope, {
+ enabled: canAdmin && open && !redirectManagement && !redirectPersonalSetup,
+ })
+ const knowledgeBaseId = index.data?.knowledgeBaseId ?? undefined
+
+ const close = () => {
+ if (prepare.isPending) return
+ if (selectedType !== null) void setSelectedType(null)
+ if (managedSource !== null) void setManagedSource(null)
+ }
+ const failedQuery = index.isError ? index : null
+ const initialMode = (type: string) =>
+ type === 'github' || type === 'slack' || setup['source-access'] === 'members'
+ ? ('members' as const)
+ : ('admin' as const)
+
+ const selectedAccessMode = selectedType ? initialMode(selectedType) : undefined
+ const selectedAvailability = selectedMeta
+ ? getConnectorAccessAvailability(selectedMeta, integrationAvailability, {
+ memberAccessAvailable,
+ mirroredAccessAvailable,
+ oauthServiceAvailability,
+ isIntegrationAvailabilityReady,
+ })
+ : undefined
+ const selectedAvailable = selectedAvailability
+ ? selectedAccessMode === 'admin'
+ ? selectedAvailability.admin
+ : selectedAvailability.members
+ : false
+ const canPrepareSelected =
+ canAdmin &&
+ open &&
+ !redirectManagement &&
+ !redirectPersonalSetup &&
+ !index.isPending &&
+ !index.isError &&
+ !integrationAvailabilityError &&
+ selectedAvailable
+ const userId = session?.user?.id
+
+ /** A known provider only needs the canonical index prepared, not another selection step. */
+ useEffect(() => {
+ if (!selectedType || knowledgeBaseId) {
+ attemptedPreparation.current = null
+ return
+ }
+ if (!canPrepareSelected || !selectedAccessMode || !userId || preparing) return
+ const requestKey = `${userId}:${resourceScopeKey(scope)}:${selectedType}:${selectedAccessMode}`
+ if (attemptedPreparation.current === requestKey) return
+ attemptedPreparation.current = requestKey
+ prepareSource({
+ ...resourceScopeFields(scope),
+ connectorType: selectedType,
+ accessMode: selectedAccessMode,
+ })
+ }, [
+ selectedType,
+ knowledgeBaseId,
+ canPrepareSelected,
+ selectedAccessMode,
+ userId,
+ preparing,
+ scope,
+ prepareSource,
+ ])
+
+ const handleCreated = async (type: string, connector: ConnectorData, accessMode: string) => {
+ await setSelectedType(null)
+ const destination = organizationRoutes(scope.organizationId).searchSource(connector.id)
+ if (accessMode === 'admin' && type !== 'gitlab') {
+ updateSearchIntegration.mutate(
+ {
+ organizationId: scope.organizationId,
+ connectorType: type,
+ approved: true,
+ policy: {
+ ...defaultLiveSearchPolicy(type),
+ accessMode: 'service_account',
+ sourceId: connector.id,
+ },
+ },
+ {
+ onSuccess: () => router.push(destination),
+ onError: (error) => {
+ toast.error(`Source added but search is not ready: ${error.message}`)
+ router.push(destination)
+ },
+ }
+ )
+ } else {
+ router.push(destination)
+ }
+ }
+
+ const normalizedSearch = search.trim().toLowerCase()
+ const visibleTypes = SEARCH_SOURCE_TYPES.filter(
+ ([type, meta]) =>
+ (setup['source-access'] === 'members' ||
+ meta.mirrorsSourceAcls ||
+ type === 'slack' ||
+ type === 'github') &&
+ `${meta.name} ${meta.description}`.toLowerCase().includes(normalizedSearch)
+ )
+
+ const availabilityOf = (meta: Parameters[0]) =>
+ getConnectorAccessAvailability(meta, integrationAvailability, {
+ memberAccessAvailable,
+ mirroredAccessAvailable,
+ oauthServiceAvailability,
+ isIntegrationAvailabilityReady,
+ })
+ const startSetup = (type: string) => {
+ if (knowledgeBaseId) void setSelectedType(searchSetupParam.parser.parse(type))
+ else
+ prepare.mutate(
+ {
+ ...resourceScopeFields(scope),
+ connectorType: type,
+ accessMode: initialMode(type),
+ },
+ {
+ onSuccess: () => void setSelectedType(searchSetupParam.parser.parse(type)),
+ }
+ )
+ }
+ const retryPrepare = () => {
+ if (!selectedType || !canPrepareSelected || !selectedAccessMode || !userId || preparing) return
+ prepareSource({
+ ...resourceScopeFields(scope),
+ connectorType: selectedType,
+ accessMode: selectedAccessMode,
+ })
+ }
+ /** Setup is resolved once the index exists; only then does the connector form have a target. */
+ const canShowForm =
+ !failedQuery &&
+ Boolean(knowledgeBaseId) &&
+ (isIntegrationAvailabilityReady || integrationAvailability.size > 0)
+ const hidden = !canAdmin || !open || redirectManagement || redirectPersonalSetup
+ const waitingForPreparation =
+ Boolean(selectedType) &&
+ !failedQuery &&
+ !integrationAvailabilityError &&
+ !prepare.error &&
+ (!isIntegrationAvailabilityReady || selectedAvailable)
+ return {
+ availabilityOf,
+ canShowForm,
+ close,
+ failedQuery,
+ handleCreated,
+ hidden,
+ index,
+ initialMode,
+ integrationAvailabilityError,
+ isIntegrationAvailabilityFetching,
+ isIntegrationAvailabilityLoading,
+ knowledgeBaseId,
+ managedSource,
+ prepare,
+ preparing,
+ refetchIntegrationAvailability,
+ retryPrepare,
+ search,
+ selectedAccessMode,
+ selectedAvailable,
+ selectedType,
+ setSearch,
+ setSelectedType,
+ startSetup,
+ userId,
+ visibleTypes,
+ waitingForPreparation,
+ }
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/knowledge-graphs.tsx b/apps/sim/app/o/[organizationId]/settings/components/knowledge-graphs.tsx
deleted file mode 100644
index 8b1e1f23ee6..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/knowledge-graphs.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import {
- Chip,
- ChipInput,
- ChipModal,
- ChipModalBody,
- ChipModalError,
- ChipModalField,
- ChipModalFooter,
- ChipModalHeader,
- ChipTag,
- toast,
-} from '@sim/emcn'
-import { Database, Plus } from '@sim/emcn/icons'
-import { SettingsPanel } from '@/components/settings/settings-panel'
-import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import {
- RESOURCE_LIST_STACK,
- SettingsResourceRow,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import {
- useCreateMemorySpace,
- useMemorySpaces,
- useSelectMemorySpace,
-} from '@/hooks/queries/memory-spaces'
-
-interface KnowledgeGraphsProps {
- organizationId: string
-}
-
-export function KnowledgeGraphs({ organizationId }: KnowledgeGraphsProps) {
- const { data, isLoading, error } = useMemorySpaces(organizationId)
- const select = useSelectMemorySpace(organizationId)
- const [creating, setCreating] = useState(false)
- return (
- setCreating(true),
- disabled: isLoading || !!error || select.isPending,
- },
- ]}
- >
- {error ? (
- {error.message}
- ) : isLoading ? (
- Loading knowledge graphs...
- ) : (
-
- {data?.spaces.map((space) => (
- }
- title={space.name}
- description={
- space.id === null
- ? 'Your original knowledge graph, preserved.'
- : 'Private to you in this organization.'
- }
- trailing={
- space.id === data.activeSpaceId ? (
- Active
- ) : (
-
- select.mutate(
- { spaceId: space.id },
- { onError: (failure) => toast.error(failure.message) }
- )
- }
- >
- Make active
-
- )
- }
- />
- ))}
-
- )}
- {creating && (
- setCreating(false)} />
- )}
-
- )
-}
-
-interface CreateKnowledgeGraphProps extends KnowledgeGraphsProps {
- onClose: () => void
-}
-function CreateKnowledgeGraph({ organizationId, onClose }: CreateKnowledgeGraphProps) {
- const create = useCreateMemorySpace(organizationId)
- const [name, setName] = useState('')
- const submit = () => create.mutate({ name: name.trim() }, { onSuccess: onClose })
- return (
- !open && onClose()}
- dismissDisabled={create.isPending}
- srTitle='New knowledge graph'
- >
- New knowledge graph
-
-
- setName(event.target.value)}
- />
-
- {create.error?.message}
-
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-recently-deleted.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-recently-deleted.tsx
deleted file mode 100644
index 51082068ba5..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/organization-recently-deleted.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-'use client'
-
-import { Chip, toast } from '@sim/emcn'
-import { Task } from '@sim/emcn/icons'
-import { formatDate } from '@sim/utils/formatting'
-import { SettingsPanel } from '@/components/settings/settings-panel'
-import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import {
- RESOURCE_LIST_STACK,
- SettingsResourceRow,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
-import {
- type MothershipChatMetadata,
- useOrganizationMothershipChats,
- useRestoreMothershipChat,
-} from '@/hooks/queries/mothership-chats'
-
-interface DeletedChatRowProps {
- organizationId: string
- chat: MothershipChatMetadata
-}
-
-function DeletedChatRow({ organizationId, chat }: DeletedChatRowProps) {
- const { mutate: restoreChat, isPending } = useRestoreMothershipChat({ organizationId })
- return (
- }
- title={chat.name}
- description={chat.deletedAt ? `Deleted ${formatDate(chat.deletedAt)}` : undefined}
- trailing={
- restoreChat(chat.id, { onError: (error) => toast.error(error.message) })}
- >
- {isPending ? 'Restoring' : 'Restore'}
-
- }
- />
- )
-}
-
-interface OrganizationRecentlyDeletedProps {
- organizationId: string
-}
-
-export function OrganizationRecentlyDeleted({ organizationId }: OrganizationRecentlyDeletedProps) {
- const [search, setSearch] = useSettingsSearch()
- const {
- data: chats = [],
- isLoading,
- error,
- } = useOrganizationMothershipChats(organizationId, 'archived')
- const searchTerm = search.trim().toLowerCase()
- const filtered = chats
- .filter((chat) => chat.name.toLowerCase().includes(searchTerm))
- .sort(
- (a, b) =>
- (b.deletedAt?.getTime() ?? 0) - (a.deletedAt?.getTime() ?? 0) ||
- a.name.localeCompare(b.name) ||
- a.id.localeCompare(b.id)
- )
-
- return (
-
- {error ? (
- {error.message}
- ) : isLoading ? null : filtered.length === 0 ? (
-
- {searchTerm && chats.length > 0 ? 'No chats match your search' : 'No deleted chats'}
-
- ) : (
-
- {filtered.map((chat) => (
-
- ))}
-
- )}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx
deleted file mode 100644
index 7607f4c93ed..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-'use client'
-
-import { getSearchMcpUrl } from '@/lib/knowledge/mcp/urls'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { SearchMcpConnection } from '@/app/o/[organizationId]/settings/components/search-mcp-connection'
-
-export function OrganizationSearchMcp() {
- const { organization, viewer } = useOrganizationContext()
- if (!viewer.canUseSearchMcp) {
- return (
-
- Your organization’s policy disables Sim Search MCP access. Contact an organization admin to
- enable it.
-
- )
- }
- const endpoint = getSearchMcpUrl(organization.id)
-
- return (
-
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.tsx
deleted file mode 100644
index 44242a5a907..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.tsx
+++ /dev/null
@@ -1,235 +0,0 @@
-'use client'
-
-import { useEffect, useRef, useState } from 'react'
-import { Chip, ChipConfirmModal, ChipLink, ChipModalError, ChipTag, useToast } from '@sim/emcn'
-import { useQueryState } from 'nuqs'
-import { SlackIcon } from '@/components/icons'
-import { SlackSearchSetupWizard } from '@/components/integrations/slack-search-setup-wizard'
-import { SettingsPanel } from '@/components/settings/settings-panel'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { slackSetupResultParam } from '@/app/o/[organizationId]/settings/components/search-params'
-import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
-import {
- SettingsEmptyState,
- SettingsQueryErrorState,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import {
- RESOURCE_LIST_STACK,
- SettingsResourceRow,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
-import {
- useConfigureSlackSearch,
- useRemoveSlackSearch,
- useSlackSearchInstallations,
-} from '@/hooks/queries/slack-search'
-
-/** Organization-owned Search bots are installed through the dedicated OAuth wizard. */
-export function OrganizationSearchSlack() {
- const setupToastShown = useRef(false)
- const { toast } = useToast()
- const { organization, viewer } = useOrganizationContext()
- const installations = useSlackSearchInstallations(viewer.isAdmin ? organization.id : undefined)
- const configure = useConfigureSlackSearch()
- const remove = useRemoveSlackSearch()
- const [setupResult, setSetupResult] = useQueryState(
- slackSetupResultParam.key,
- slackSetupResultParam.parser
- )
- const [wizard, setWizard] = useState<{
- mode?: 'custom' | 'shared'
- installationId?: string
- appId?: string
- initialName?: string
- } | null>(null)
- const [removeTarget, setRemoveTarget] = useState<{ id: string; name: string } | null>(null)
-
- useEffect(() => {
- if (!viewer.isAdmin || setupResult !== 'complete' || setupToastShown.current) return
- setupToastShown.current = true
- toast.success('Slack connected')
- void setSetupResult(null)
- }, [viewer.isAdmin, setupResult, setSetupResult, toast])
-
- if (!viewer.isAdmin) return null
- const busy = configure.isPending || remove.isPending
- const bots = installations.data?.bots ?? []
- const canInstallSharedApp = !installations.error && installations.data?.sharedAppAvailable
- const sharedTeams = new Set(
- installations.data?.installations
- .filter((installation) => installation.appKind === 'shared')
- .map((installation) => installation.teamId)
- )
-
- return (
-
-
-
- {installations.error ? (
- void installations.refetch()}
- variant='inline'
- />
- ) : !installations.data ? (
- Loading Slack connection
- ) : (
-
- {installations.data.installations.length === 0 ? (
- }
- title='Slack'
- description='Connect your workspace to ask questions in Slack.'
- trailing={
- setWizard(canInstallSharedApp ? { mode: 'shared' } : {})}
- >
- {installations.data.sharedAppAvailable ? 'Install Sim Search' : 'Set up'}
-
- }
- />
- ) : (
- installations.data.installations.map((installation) => {
- const custom = installation.appKind === 'custom'
- const name = custom ? 'Sim Search (custom bot)' : 'Sim Search'
- const needsInstall =
- canInstallSharedApp && custom && !sharedTeams.has(installation.teamId)
- const connectionError = needsInstall
- ? null
- : installation.needsValidation
- ? 'Reconnect to verify the app’s credentials and permissions.'
- : ['delivery_failed', 'assistant_or_delivery_failed'].includes(
- installation.lastOutcome ?? ''
- )
- ? 'The last reply failed. Check the Slack connection.'
- : null
- return (
- }
- title={name}
- description={
- connectionError ? (
- {connectionError}
- ) : (
- installation.teamName
- )
- }
- badge={
-
- {needsInstall || installation.needsValidation
- ? 'Reconnect required'
- : installation.enabled
- ? 'Enabled'
- : 'Disabled'}
-
- }
- trailing={
- <>
-
- Open in Slack
-
-
- setWizard({
- mode: needsInstall ? 'shared' : installation.appKind,
- installationId: installation.id,
- appId: installation.appId,
- initialName:
- custom && !needsInstall
- ? bots.find((bot) => bot.id === installation.credentialId)
- ?.displayName
- : undefined,
- }),
- },
- {
- label: installation.enabled ? 'Disable' : 'Enable',
- disabled:
- busy || (!installation.enabled && installation.needsValidation),
- tooltip:
- installation.needsValidation && !installation.enabled
- ? 'Reconnect before enabling Search'
- : undefined,
- onSelect: () =>
- configure.mutate({
- organizationId: organization.id,
- credentialId: installation.credentialId,
- enabled: !installation.enabled,
- }),
- },
- {
- label: 'Remove from Search',
- destructive: true,
- disabled: busy,
- onSelect: () => {
- remove.reset()
- setRemoveTarget({ id: installation.id, name })
- },
- },
- ]}
- />
- >
- }
- />
- )
- })
- )}
-
- )}
-
- {configure.error && (
-
- {configure.error.message}
-
- )}
- {configure.isPending && (
-
- Updating the Slack connection
-
- )}
-
- {removeTarget && (
- {
- if (!open) setRemoveTarget(null)
- }}
- title='Remove Slack Search'
- text={`Remove ${removeTarget.name} from Slack Search? Pending replies will be cancelled.`}
- confirm={{
- label: 'Remove',
- pending: remove.isPending,
- pendingLabel: 'Removing',
- onClick: () =>
- remove.mutate(
- { organizationId: organization.id, installationId: removeTarget.id },
- { onSuccess: () => setRemoveTarget(null) }
- ),
- }}
- >
- {remove.error?.message}
-
- )}
- {wizard && (
- setWizard(null)}
- />
- )}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.test.tsx
deleted file mode 100644
index 0a0a7237fc4..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.test.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-/** @vitest-environment jsdom */
-import { act } from 'react'
-import { createRoot, type Root } from 'react-dom/client'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { SearchMcpConnection } from '@/app/o/[organizationId]/settings/components/search-mcp-connection'
-
-const ENDPOINT = 'https://sim.fixture.test/api/mcp/search/organizations/org-1'
-
-describe('Search MCP client connection', () => {
- let root: Root
- let container: HTMLDivElement
- const writeText = vi.fn().mockResolvedValue(undefined)
-
- beforeEach(() => {
- vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
- Object.defineProperty(navigator, 'clipboard', {
- configurable: true,
- value: { writeText },
- })
- writeText.mockReset().mockResolvedValue(undefined)
- container = document.createElement('div')
- document.body.appendChild(container)
- root = createRoot(container)
- })
-
- afterEach(async () => {
- await act(async () => root.unmount())
- container.remove()
- })
-
- async function render(endpoint = ENDPOINT) {
- await act(async () => root.render( ))
- }
-
- async function selectClient(label: string) {
- const trigger = container.querySelector('[aria-label^="MCP app: "]')!
- await act(async () => {
- trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
- })
- const item = Array.from(document.querySelectorAll('[role="menuitemradio"]')).find(
- (element) => element.textContent?.trim() === label
- )
- expect(item, `Expected the ${label} client`).toBeDefined()
- await act(async () => item!.click())
- expect(trigger.getAttribute('aria-label')).toBe(`MCP app: ${label}`)
- }
-
- async function copyConfiguration() {
- const button = Array.from(container.querySelectorAll('button')).find(
- (element) =>
- /^Copy (configuration|command|MCP server URL)$/.test(
- element.getAttribute('aria-label') ?? element.textContent?.trim() ?? ''
- )
- )
- expect(button).toBeDefined()
- await act(async () => button!.click())
- return writeText.mock.lastCall?.[0] as string
- }
-
- it('quotes a shell metacharacter in the copied endpoint as a literal', async () => {
- await render("https://sim.fixture.test/api/mcp/search/organizations/org'$(example)")
- await selectClient('Claude Code')
- expect(await copyConfiguration()).toBe(
- "claude mcp add --transport http sim-search 'https://sim.fixture.test/api/mcp/search/organizations/org'\\''$(example)'"
- )
- })
-})
diff --git a/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx b/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx
deleted file mode 100644
index 09641a3295c..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import {
- Chip,
- ChipModalField,
- ChipSelect,
- Code,
- chipFieldSurfaceClass,
- useCopyToClipboard,
-} from '@sim/emcn'
-import { Check, Duplicate } from '@sim/emcn/icons'
-
-const CLIENTS = [
- { value: 'claude', label: 'Claude' },
- { value: 'codex', label: 'Codex' },
- { value: 'claude-code', label: 'Claude Code' },
- { value: 'cursor', label: 'Cursor' },
- { value: 'devin', label: 'Devin' },
- { value: 'other', label: 'Other' },
-] as const
-
-type SearchMcpClient = (typeof CLIENTS)[number]['value']
-
-interface SearchMcpConnectionProps {
- endpoint: string
-}
-
-export function SearchMcpConnection({ endpoint }: SearchMcpConnectionProps) {
- const [client, setClient] = useState('claude')
- const quotedEndpoint = `'${endpoint.replace(/'/g, "'\\''")}'`
- const configuration =
- client === 'codex'
- ? `codex mcp add sim-search --url ${quotedEndpoint}`
- : client === 'claude-code'
- ? `claude mcp add --transport http sim-search ${quotedEndpoint}`
- : JSON.stringify({ mcpServers: { 'sim-search': { url: endpoint } } }, null, 2)
-
- return (
- <>
-
-
- {
- const option = CLIENTS.find((item) => item.value === value)
- if (option) setClient(option.value)
- }}
- options={[...CLIENTS]}
- aria-label={`MCP app: ${CLIENTS.find((option) => option.value === client)?.label}`}
- align='start'
- fullWidth
- dropdownWidth='trigger'
- />
-
-
- {client !== 'cursor' ? (
-
- ) : (
-
- {(aria) => (
- <>
-
-
- >
- )}
-
- )}
- >
- )
-}
-
-interface CopyConfigurationProps {
- code: string
- label: string
- descriptionId?: string
-}
-
-function CopyConfiguration({ code, label, descriptionId }: CopyConfigurationProps) {
- const { copied, copy } = useCopyToClipboard()
-
- return (
- void copy(code)}
- aria-describedby={descriptionId}
- >
- {label}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/layout.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/layout.tsx
deleted file mode 100644
index c0b0209571b..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/layout.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { ReactNode } from 'react'
-import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
-
-interface OrganizationProviderLayoutProps {
- children: ReactNode
-}
-
-export default function OrganizationProviderLayout({ children }: OrganizationProviderLayoutProps) {
- return (
-
- {children}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/loading.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/loading.tsx
new file mode 100644
index 00000000000..e6bf9711f89
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/loading.tsx
@@ -0,0 +1,11 @@
+import { SettingsSubpageHost } from '@/platform/app/(shell)/settings/_components/settings-subpage-host'
+import { LoadingRows } from '@/platform/design-system/components'
+
+/** Keeps the section's heading visible while authorization and content load. */
+export default function OrganizationProviderLoading() {
+ return (
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.test.tsx
index f484fd09f17..4f70ad3d389 100644
--- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.test.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.test.tsx
@@ -16,11 +16,14 @@ vi.mock('@/lib/sim-search/connectors', () => ({
],
}))
vi.mock(
- '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail',
- () => ({ OrganizationProviderDetail: () => null })
+ '@/platform/app/(shell)/settings/_components/sections/integrations/provider-detail',
+ () => ({ ProviderDetail: () => null })
)
-vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({
- SettingsEmptyState: () => null,
+vi.mock('@/platform/app/(shell)/settings/_components/settings-subpage-host', () => ({
+ SettingsSubpageHost: () => null,
+}))
+vi.mock('@/platform/app/(shell)/settings/_components/section-parts/section-parts', () => ({
+ LoadingState: () => null,
}))
import OrganizationProviderPage from '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page'
diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.tsx
index f21f8e30894..9ceea2ac4be 100644
--- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.tsx
@@ -9,8 +9,9 @@ import { LIVE_SEARCH_SERVICE_PROVIDERS } from '@/lib/sim-search/live/policy-sche
import { searchSetupParam } from '@/lib/sim-search/params'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { serializeOrganizationPeople } from '@/app/o/[organizationId]/settings/components/integrations/search-params'
-import { OrganizationProviderDetail } from '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail'
-import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import { ProviderDetail } from '@/platform/app/(shell)/settings/_components/sections/integrations/provider-detail'
+import { SettingsSubpageHost } from '@/platform/app/(shell)/settings/_components/settings-subpage-host'
+import { LoadingRows } from '@/platform/design-system/components'
interface OrganizationProviderPageProps {
params: Promise<{ organizationId: string; connectorType: string }>
@@ -71,10 +72,10 @@ export default async function OrganizationProviderPage({
)
}
return (
- Loading integration}
- >
-
-
+
+ }>
+
+
+
)
}
diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx
deleted file mode 100644
index b053227406e..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx
+++ /dev/null
@@ -1,277 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { ArrowLeft, Plus } from '@sim/emcn/icons'
-import { useRouter } from 'next/navigation'
-import { useQueryState, useQueryStates } from 'nuqs'
-import type { SettingsAction } from '@/components/settings/settings-header'
-import { SettingsPanel } from '@/components/settings/settings-panel'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { getSearchConnectionLabels } from '@/lib/sim-search/connection-labels'
-import { getConnectorAccessAvailability } from '@/lib/sim-search/connectors'
-import { searchSetupAccessParam, searchSetupParam } from '@/lib/sim-search/params'
-import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { connectedAccountsParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params'
-import { SearchSourceSetup } from '@/app/o/[organizationId]/settings/components/integrations/search-source-setup'
-import { OrganizationSlackAccountRemoval } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-removal'
-import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup'
-import {
- SettingsEmptyState,
- SettingsQueryErrorState,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import { SettingsListPagination } from '@/app/workspace/[workspaceId]/settings/components/settings-list-pagination'
-import {
- RESOURCE_LIST_STACK,
- SettingsResourceRow,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
-import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
-import { useSearchSources } from '@/hooks/queries/kb/connectors'
-import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts'
-import { useSearchIntegrations } from '@/hooks/queries/search-integrations'
-import { useDebounce } from '@/hooks/use-debounce'
-import { usePermissionConfig } from '@/hooks/use-permission-config'
-
-interface OrganizationProviderDetailProps {
- connectorType: string
-}
-
-export function OrganizationProviderDetail({ connectorType }: OrganizationProviderDetailProps) {
- const { organization, viewer, searchAccess } = useOrganizationContext()
- const router = useRouter()
- const meta = CONNECTOR_META_REGISTRY[connectorType]
- const [search, setSearch] = useSettingsSearch()
- const sourceSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS)
- const [removingSlackAccounts, setRemovingSlackAccounts] = useState(false)
- const scope = { kind: 'organization', organizationId: organization.id } as const
- const overview = useSearchIntegrations(organization.id)
- const sources = useSearchSources(scope, {
- connectorType,
- search: sourceSearch,
- enabled: viewer.isAdmin,
- })
- const availability = usePermissionConfig()
- const accounts = useOrganizationAccounts(
- viewer.isAdmin && connectorType === 'slack' ? organization.id : undefined
- )
- const [, setSetup] = useQueryStates(
- {
- [searchSetupParam.key]: searchSetupParam.parser,
- [searchSetupAccessParam.key]: searchSetupAccessParam.parser,
- },
- { history: 'replace' }
- )
- const [, setConnectedAccounts] = useQueryState(
- connectedAccountsParam.key,
- connectedAccountsParam.parser
- )
- const provider = overview.data?.find((item) => item.connectorType === connectorType)
- const approved = provider?.approved === true
- const back = {
- text: 'Sources',
- icon: ArrowLeft,
- onSelect: () =>
- router.push(organizationRoutes(organization.id).settingsSection('integrations')),
- }
- if (!viewer.isAdmin || !meta) return null
- const labels = getSearchConnectionLabels(connectorType)
- const searchField = {
- value: search,
- onChange: setSearch,
- placeholder: labels.searchPlaceholder,
- }
- const access = getConnectorAccessAvailability(meta, availability.integrationAvailability, {
- memberAccessAvailable: searchAccess.memberScoped,
- mirroredAccessAvailable: searchAccess.sourceMirrored,
- oauthServiceAvailability: availability.oauthServiceAvailability,
- isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady,
- })
- const unavailable =
- availability.isIntegrationAvailabilityReady && !access.admin && !access.members
- const panel = {
- back,
- title: meta.name,
- description:
- approved && unavailable
- ? 'Unavailable in this deployment'
- : provider
- ? connectorType === 'gitlab'
- ? 'Projects and permissions'
- : connectorType === 'github'
- ? 'GitHub App repositories'
- : 'Service account connections'
- : undefined,
- docsLink: meta.searchDocsUrl,
- search: searchField,
- }
- const option = accounts.data?.credentialGroup?.options.find(
- (item) => item.provider === 'slack' && item.status === 'active'
- )
- const group = accounts.data?.credentialGroup
- const removalActions: SettingsAction[] =
- connectorType === 'slack' &&
- !accounts.isError &&
- group?.options.some((item) => item.provider === 'slack')
- ? [
- {
- id: 'delete',
- text: 'Remove app setup',
- disabled: accounts.isFetching,
- onSelect: () => setRemovingSlackAccounts(true),
- },
- ]
- : []
- const needsSlackSetup =
- connectorType === 'slack' &&
- (option?.provider !== 'slack' || option.configurationStatus !== 'ready')
- const pending =
- overview.isPending || overview.isError || !availability.isIntegrationAvailabilityReady
- const startSource = () =>
- void setSetup({
- addConnector: searchSetupParam.parser.parse(connectorType),
- 'source-access': access.admin ? null : 'members',
- })
- const actions: SettingsAction[] = approved
- ? [
- ...(needsSlackSetup || access.admin || (connectorType === 'github' && access.members)
- ? [
- {
- text: needsSlackSetup
- ? 'Set up Slack app'
- : connectorType === 'github'
- ? 'Add repository'
- : connectorType !== 'gitlab'
- ? 'Add service account'
- : getSearchConnectionLabels(connectorType, access.admin ? 'admin' : 'members')
- .add,
- icon: Plus,
- variant: 'primary' as const,
- disabled:
- pending ||
- (connectorType === 'slack' && (accounts.isPending || accounts.isError)),
- onSelect: needsSlackSetup ? () => void setConnectedAccounts('slack') : startSource,
- },
- ]
- : []),
- ]
- : [
- {
- text: 'View sources',
- variant: 'primary',
- disabled: pending || (!access.admin && !access.members),
- tooltip: unavailable ? 'This integration is unavailable in this deployment.' : undefined,
- onSelect: () =>
- router.push(organizationRoutes(organization.id).settingsSection('integrations')),
- },
- ]
- actions.push(...removalActions)
- if (overview.isError)
- return (
-
- void overview.refetch()}
- variant='inline'
- />
-
- )
- if (overview.isPending)
- return (
-
- Loading integration
-
- )
-
- const renderSources = () => (
-
- {availability.integrationAvailabilityError && (
- void availability.refetchIntegrationAvailability()}
- variant='inline'
- />
- )}
- {connectorType === 'slack' && accounts.isError && (
- void accounts.refetch()}
- variant='inline'
- />
- )}
- {sources.isError && !sources.isFetchNextPageError ? (
- void sources.refetch()}
- variant='inline'
- />
- ) : sources.isPending ? (
- Loading connections
- ) : (
-
- {sources.data
- ?.filter(
- (source) =>
- source.accessMode === 'admin' ||
- (connectorType === 'github' && source.isGitHubInstallation)
- )
- .map((source) => (
-
- ))}
- {!sources.data?.some(
- (source) =>
- source.accessMode === 'admin' ||
- (connectorType === 'github' && source.isGitHubInstallation)
- ) &&
- !sources.hasNextPage && (
-
- {sourceSearch
- ? 'No matching connections'
- : unavailable
- ? `${meta.name} must be configured for this deployment before you can add a connection.`
- : !approved
- ? 'Activate this integration to add a connection.'
- : labels.empty}
-
- )}
-
-
- )}
-
- )
- return (
- <>
- {renderSources()}
-
-
- {removingSlackAccounts && group && (
- setRemovingSlackAccounts(false)}
- onRemoved={() => setRemovingSlackAccounts(false)}
- />
- )}
- >
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/secrets/page.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/secrets/page.tsx
index b849ec80268..40cd702d4f4 100644
--- a/apps/sim/app/o/[organizationId]/settings/integrations/secrets/page.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/integrations/secrets/page.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from 'next'
-import { OrganizationSecretsPage } from '@/components/secrets/organization-secrets-page'
+import { OrganizationSecretsPage } from '@/app/o/[organizationId]/integrations/secrets/organization-secrets-page'
export const metadata: Metadata = { title: 'Generic Secrets' }
diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/layout.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/layout.tsx
deleted file mode 100644
index d41d1a8b1e3..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/layout.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { ReactNode } from 'react'
-import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
-
-interface OrganizationSourceLayoutProps {
- children: ReactNode
-}
-
-export default function OrganizationSourceLayout({ children }: OrganizationSourceLayoutProps) {
- return (
-
- {children}
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/loading.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/loading.tsx
index 8515a36bff5..6e423a3c84c 100644
--- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/loading.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/loading.tsx
@@ -1,4 +1,11 @@
-/** Keeps the source layout's heading visible while authorization and content load. */
+import { SettingsSubpageHost } from '@/platform/app/(shell)/settings/_components/settings-subpage-host'
+import { LoadingRows } from '@/platform/design-system/components'
+
+/** Keeps the section's heading visible while authorization and content load. */
export default function OrganizationSourceLoading() {
- return null
+ return (
+
+
+
+ )
}
diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/page.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/page.test.tsx
index 1cc8b53ea9d..36be1e974d8 100644
--- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/page.test.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/page.test.tsx
@@ -1,4 +1,3 @@
-import { Suspense } from 'react'
import { createRouteContext } from '@sim/testing/helpers/http'
import { authMockFns } from '@sim/testing/mocks/auth.mock'
import { nextNavigationMock, nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock'
@@ -11,17 +10,13 @@ vi.mock('@/lib/settings/application/organization-section-access', () => ({
authorizeOrganizationSettingsSection: mocks.authorize,
}))
vi.mock('next/navigation', () => nextNavigationMock)
-vi.mock('@/components/settings/settings-header', () => ({
- SettingsHeaderProvider: 'header-provider',
- SettingsHeaderShell: 'header-shell',
+vi.mock('@/platform/app/(shell)/settings/_components/sections/integrations/source-detail', () => ({
+ SourceDetail: 'source-detail',
+}))
+vi.mock('@/platform/app/(shell)/settings/_components/settings-subpage-host', () => ({
+ SettingsSubpageHost: 'settings-subpage-host',
}))
-vi.mock(
- '@/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail',
- () => ({ OrganizationSourceDetail: 'source-detail' })
-)
-import OrganizationSourceLayout from '@/app/o/[organizationId]/settings/integrations/sources/[connectorId]/layout'
-import OrganizationSourceLoading from '@/app/o/[organizationId]/settings/integrations/sources/[connectorId]/loading'
import OrganizationSourcePage from '@/app/o/[organizationId]/settings/integrations/sources/[connectorId]/page'
const mockRedirect = nextNavigationMockFns.mockRedirect
@@ -35,25 +30,13 @@ describe('organization source page authorization', () => {
mocks.authorize.mockResolvedValue(true)
})
it('gates the source page with the existing target-organization integration policy', async () => {
- const page = await OrganizationSourcePage(props)
- expect(page.type).toBe(Suspense)
- expect(page.props.fallback.type).toBe(OrganizationSourceLoading)
+ await OrganizationSourcePage(props)
expect(mocks.authorize).toHaveBeenCalledWith({
organizationId: 'org-one',
userId: 'admin-one',
section: 'integrations',
})
})
- it('renders the persistent heading without waiting for page authorization', () => {
- const layout = OrganizationSourceLayout({ children: })
- expect(layout.type).toBe('header-provider')
- expect(layout.props.children).toMatchObject({
- type: 'header-shell',
- props: { meta: { title: 'Search source' } },
- })
- expect(mockGetSession).not.toHaveBeenCalled()
- expect(mocks.authorize).not.toHaveBeenCalled()
- })
it('does not render when the admin or feature gate denies access', async () => {
mocks.authorize.mockResolvedValue(false)
await expect(OrganizationSourcePage(props)).rejects.toThrow('NEXT_NOT_FOUND')
diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/page.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/page.tsx
index 9b83e824d99..631f93260a1 100644
--- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/page.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/page.tsx
@@ -5,8 +5,9 @@ import { getSession } from '@/lib/auth'
import { organizationRoutes } from '@/lib/navigation/paths'
import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
-import OrganizationSourceLoading from '@/app/o/[organizationId]/settings/integrations/sources/[connectorId]/loading'
-import { OrganizationSourceDetail } from '@/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail'
+import { SourceDetail } from '@/platform/app/(shell)/settings/_components/sections/integrations/source-detail'
+import { SettingsSubpageHost } from '@/platform/app/(shell)/settings/_components/settings-subpage-host'
+import { LoadingRows } from '@/platform/design-system/components'
export const metadata: Metadata = { title: 'Search source' }
@@ -35,8 +36,10 @@ export default async function OrganizationSourcePage({ params }: OrganizationSou
notFound()
return (
- }>
-
-
+
+ }>
+
+
+
)
}
diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx
deleted file mode 100644
index 5cf76ca42ab..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx
+++ /dev/null
@@ -1,378 +0,0 @@
-'use client'
-
-import { type ReactNode, useState } from 'react'
-import { ChipLink } from '@sim/emcn'
-import { ArrowLeft } from '@sim/emcn/icons'
-import { useRouter } from 'next/navigation'
-import { saveDiscardActions } from '@/components/settings/save-discard-actions'
-import type { SettingsAction, SettingsBackAction } from '@/components/settings/settings-header'
-import { SettingsPanel } from '@/components/settings/settings-panel'
-import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
-import { isApiClientError } from '@/lib/api/client/errors'
-import type { ConnectorData, ConnectorDetailData } from '@/lib/api/contracts/knowledge/connectors'
-import type { ResourceScope } from '@/lib/core/resource-scope'
-import { organizationRoutes } from '@/lib/navigation/paths'
-import { describeSearchSource } from '@/lib/sim-search/source-identity'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { ConnectorActionFeedback } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions'
-import { getConnectorSyncState } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-state'
-import { useConnectorActions } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/use-connector-actions'
-import { ConnectorSettingsFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields'
-import { useConnectorSettingsForm } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form'
-import {
- SettingsEmptyState,
- SettingsQueryErrorState,
-} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
-import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
-import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
-import {
- isConnectorSyncingOrPending,
- useConnectorDetail,
- useSearchIndex,
-} from '@/hooks/queries/kb/connectors'
-import { useSearchIntegrations } from '@/hooks/queries/search-integrations'
-import { useOAuthReturnForKBConnectors } from '@/hooks/use-oauth-return'
-
-interface OrganizationSourceDetailProps {
- connectorId: string
-}
-
-export function OrganizationSourceDetail({ connectorId }: OrganizationSourceDetailProps) {
- const { organization, viewer } = useOrganizationContext()
- const router = useRouter()
- const scope: ResourceScope = { kind: 'organization', organizationId: organization.id }
- const backHref = organizationRoutes(organization.id).settingsSection('integrations')
- const index = useSearchIndex(scope, { enabled: viewer.isAdmin })
- const knowledgeBaseId = viewer.isAdmin ? (index.data?.knowledgeBaseId ?? undefined) : undefined
- const detail = useConnectorDetail(knowledgeBaseId, connectorId)
- const back = { text: 'Sources', icon: ArrowLeft, onSelect: () => router.push(backHref) }
-
- if (!viewer.isAdmin)
- return (
-
-
- Only organization admins can manage sources.
-
-
- )
- const accessFailure = [index, detail].find(
- (query) =>
- query.isError && isApiClientError(query.error) && [401, 403, 404].includes(query.error.status)
- )
- const failedQuery = accessFailure ?? (index.isError ? index : detail.isError ? detail : null)
- const hasCanonicalDetail = Boolean(
- detail.data &&
- !detail.isPlaceholderData &&
- detail.data.id === connectorId &&
- detail.data.knowledgeBaseId === knowledgeBaseId
- )
- if (failedQuery && (!hasCanonicalDetail || accessFailure))
- return (
-
- {isApiClientError(failedQuery.error) && failedQuery.error.status === 404 ? (
-
- This connection is no longer available.
-
- ) : (
- void failedQuery.refetch()}
- fallback='Could not load connection'
- variant='inline'
- />
- )}
-
- )
- if (!index.isPending && !knowledgeBaseId)
- return (
-
-
- This connection is no longer available.
-
-
- )
- if (
- !detail.data ||
- detail.isPlaceholderData ||
- detail.data.id !== connectorId ||
- detail.data.knowledgeBaseId !== knowledgeBaseId
- )
- return (
-
- Loading connection
-
- )
- if (
- detail.data.accessMode === 'members' &&
- !(detail.data.connectorType === 'github' && detail.data.sourceConfig.githubRepositoryId)
- )
- return (
-
-
- Members search all content their connected accounts can access. Manage this integration’s
- account mode in Sources.
-
- Manage sources
-
- )
- return (
- void failedQuery.refetch()}
- fallback='Could not refresh connection'
- variant='inline'
- />
- ) : undefined
- }
- />
- )
-}
-
-interface SourceDetailContentProps {
- connector: ConnectorDetailData
- scope: ResourceScope
- backHref: string
- backText: string
- queryError?: ReactNode
-}
-
-function SourceDetailContent({
- connector,
- scope,
- backHref,
- backText,
- queryError,
-}: SourceDetailContentProps) {
- useOAuthReturnForKBConnectors(
- connector.knowledgeBaseId,
- undefined,
- connector.connectorType,
- scope,
- connector.id
- )
- const { organization } = useOrganizationContext()
- const integrations = useSearchIntegrations(organization.id)
- const router = useRouter()
- const meta = CONNECTOR_META_REGISTRY[connector.connectorType]
- const title = meta
- ? describeSearchSource(meta, connector.sourceConfig) || meta.name
- : 'Connection'
- const { effectiveStatus } = getConnectorSyncState(connector)
- const status =
- effectiveStatus === 'paused'
- ? 'Search paused'
- : effectiveStatus === 'disabled'
- ? 'Search disabled'
- : undefined
- const description =
- [title === meta?.name ? undefined : meta?.name, status].filter(Boolean).join(' · ') || undefined
- const onBack = () => router.push(backHref)
- const onRemoved = () =>
- router.replace(organizationRoutes(organization.id).settingsSection('integrations'))
- if (
- integrations.isError &&
- isApiClientError(integrations.error) &&
- [401, 403, 404].includes(integrations.error.status)
- )
- return (
-
- void integrations.refetch()}
- fallback='Could not load integration status'
- variant='inline'
- />
-
- )
- const integrationFeedback = (
- <>
- {queryError}
- {integrations.isError ? (
- void integrations.refetch()}
- fallback='Could not load integration status'
- variant='inline'
- />
- ) : integrations.isPending ? (
- Loading integration status
- ) : null}
- {integrations.data?.find((item) => item.connectorType === connector.connectorType)
- ?.approved === false && (
-
- )}
- >
- )
- return (
-
- )
-}
-
-interface SourcePanelProps {
- connector: ConnectorData
- back: SettingsBackAction
- title: string
- description?: string
- docsLink?: string
- actions?: SettingsAction[]
- lifecycleDisabled?: boolean
- onRemoved: () => void
- children: ReactNode
-}
-
-function SourcePanel({
- connector,
- actions,
- lifecycleDisabled,
- onRemoved,
- children,
- ...panel
-}: SourcePanelProps) {
- const lifecycle = useConnectorActions({
- connector,
- knowledgeBaseId: connector.knowledgeBaseId,
- canEdit: true,
- disabled: lifecycleDisabled,
- primarySync: !actions,
- onRemoved,
- })
- return (
- action.id !== 'sync')
- .map((action) =>
- action.id === 'pause'
- ? {
- ...action,
- text: connector.status === 'paused' ? 'Resume search' : 'Pause search',
- }
- : action
- ),
- ...(actions ?? []),
- ]}
- >
-
- {children}
-
- )
-}
-
-interface SourceSettingsEditorProps {
- connector: ConnectorData
- scope: ResourceScope
- title: string
- description?: string
- queryError?: ReactNode
- backText: string
- onBack: () => void
- onRemoved: () => void
-}
-
-function SourceSettingsEditor(props: SourceSettingsEditorProps) {
- const [draft, setDraft] = useState({ connector: props.connector, revision: 0 })
- const reset = (connector: ConnectorData) =>
- setDraft((previous) => ({ connector, revision: previous.revision + 1 }))
- return (
- reset(props.connector)}
- />
- )
-}
-
-interface SourceSettingsFormProps extends SourceSettingsEditorProps {
- baseline: ConnectorData
- onSaved: (connector: ConnectorData) => void
- onDiscard: () => void
-}
-
-function SourceSettingsForm({
- connector,
- baseline,
- scope,
- title,
- description,
- queryError,
- backText,
- onBack,
- onRemoved,
- onSaved,
- onDiscard,
-}: SourceSettingsFormProps) {
- const form = useConnectorSettingsForm({
- connector: baseline,
- syncing: isConnectorSyncingOrPending(connector),
- scope,
- knowledgeBaseId: connector.knowledgeBaseId,
- isSearchIndex: true,
- onSaved,
- })
- const guard = useSettingsUnsavedGuard({
- isDirty: form.dirty,
- navigationBlocked: form.saving,
- onDiscard,
- })
- return (
- guard.guardBack(onBack) }}
- title={title}
- description={description}
- docsLink={form.docsUrl}
- lifecycleDisabled={form.dirty || form.saving}
- onRemoved={onRemoved}
- actions={saveDiscardActions({
- dirty: form.dirty,
- saving: form.saving,
- saveDisabled: !form.canSave,
- saveTooltip: form.saveBlockedReason,
- onSave: form.save,
- onDiscard,
- })}
- >
- {queryError}
- {connector.connectorType === 'gitlab' && (
-
- )}
-
-
-
-
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/layout.tsx b/apps/sim/app/o/[organizationId]/settings/layout.tsx
index d4714304ae9..21421ed720c 100644
--- a/apps/sim/app/o/[organizationId]/settings/layout.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/layout.tsx
@@ -1,19 +1,10 @@
-'use client'
-
import type { ReactNode } from 'react'
-import { SettingsPendingSection } from '@/components/settings/settings-pending-section'
-import { resolveOrganizationSurfaceHeaderMeta } from '@/app/o/[organizationId]/settings/navigation'
+import { SettingsFrame } from '@/app/o/[organizationId]/settings/settings-frame'
interface OrganizationSettingsLayoutProps {
children: ReactNode
}
export default function OrganizationSettingsLayout({ children }: OrganizationSettingsLayoutProps) {
- return (
-
-
- {children}
-
-
- )
+ return {children}
}
diff --git a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx
deleted file mode 100644
index d0eaaf656dd..00000000000
--- a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-'use client'
-
-import { useQueryClient } from '@tanstack/react-query'
-import { usePathname } from 'next/navigation'
-import { ORGANIZATION_SETTINGS_GROUPS } from '@/components/settings/navigation'
-import { SettingsSidebar } from '@/components/settings/settings-sidebar'
-import { isApiClientError } from '@/lib/api/client/errors'
-import { useSession } from '@/lib/auth/client'
-import { isEnterprise } from '@/lib/billing/plan-helpers'
-import {
- hasPaidSubscriptionStatus,
- hasUsableSubscriptionAccess,
-} from '@/lib/billing/subscriptions/utils'
-import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
-import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import {
- organizationSurfaceSettingsNavigation,
- resolveOrganizationSurfaceSection,
-} from '@/app/o/[organizationId]/settings/navigation'
-import { warmOrganizationSettingsSectionQuery } from '@/app/o/[organizationId]/settings/settings-query-warmers'
-import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
-import { useOrganizationBillingSummary } from '@/hooks/queries/organization-billing-summary'
-
-interface OrganizationSettingsSidebarProps {
- isCollapsed: boolean
- showCollapsedTooltips: boolean
-}
-
-export function OrganizationSettingsSidebar(props: OrganizationSettingsSidebarProps) {
- const { organization, viewer, connectedAccountsAvailable, searchAccess, settingsFeatures } =
- useOrganizationContext()
- const knowledgeGraphs = useFeatureFlag('mothership-memory-spaces')
- const pathname = usePathname()
- const { data: session } = useSession()
- const queryClient = useQueryClient()
- const refreshPlan = viewer.isAdmin && settingsFeatures.hosted && settingsFeatures.billingEnabled
- const { data: summary, error } = useOrganizationBillingSummary(organization.id, {
- enabled: refreshPlan,
- })
- const accessDenied =
- refreshPlan && isApiClientError(error) && [401, 403, 404].includes(error.status)
- const isAdmin = viewer.isAdmin && !accessDenied
- /** Server features paint immediately; the shared summary keeps portal changes current on focus. */
- const features = {
- ...settingsFeatures,
- hasEnterprisePlan:
- refreshPlan && summary
- ? summary.data.subscriptionState === 'active' &&
- isEnterprise(summary.data.subscriptionPlan) &&
- hasUsableSubscriptionAccess(summary.data.subscriptionStatus, summary.data.billingBlocked)
- : settingsFeatures.hasEnterprisePlan,
- /**
- * Refreshed from the same summary, or the item the plan gate just hid would reappear only on
- * reload. Governance keeps its own rule — an entitled status, block state ignored — because a
- * failing payment does not stop the organization's permission groups from applying.
- */
- governanceActive:
- refreshPlan && summary
- ? isEnterprise(summary.data.subscriptionPlan) &&
- hasPaidSubscriptionStatus(summary.data.subscriptionStatus)
- : settingsFeatures.governanceActive,
- }
-
- const routes = organizationRoutes(organization.id)
-
- return (
- routes.settingsSection(section)}
- onSectionIntent={(section) =>
- warmOrganizationSettingsSectionQuery(
- queryClient,
- { organizationId: organization.id, isAdmin },
- section
- )
- }
- backHref={searchAccess.memberScoped ? routes.home : WORKSPACE_SETTINGS_PATH}
- />
- )
-}
diff --git a/apps/sim/app/o/[organizationId]/settings/settings-frame.tsx b/apps/sim/app/o/[organizationId]/settings/settings-frame.tsx
new file mode 100644
index 00000000000..0eef3a4a452
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/settings/settings-frame.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import { type ReactNode, useContext } from 'react'
+import { usePathname } from 'next/navigation'
+import { createPortal } from 'react-dom'
+import { useSettingsNavigationState } from '@/components/settings/settings-navigation-provider'
+import { MobileTitleSlot } from '@/app/workspace/[workspaceId]/components/workspace-chrome/mobile-title-slot'
+import {
+ SettingsBreadcrumb,
+ SettingsMobileTitle,
+ SettingsScreen,
+} from '@/platform/app/(shell)/settings/_components/settings-screen/settings-screen'
+import { Surface } from '@/platform/design-system/components/surface/surface'
+import { resolveOrganizationSurfaceHeaderMeta } from '@/platform/lib/data/settings-sections'
+
+interface SettingsFrameProps {
+ children: ReactNode
+}
+
+/** On a phone the title joins the navigation toggle, so one bar sits above the page's heading. */
+function PhoneTitle({ title }: { title: string }) {
+ const slot = useContext(MobileTitleSlot)
+ if (!slot) return null
+ return createPortal(
+
+
+ ,
+ slot
+ )
+}
+
+/**
+ * The breadcrumb over every section. Each section mounts its own surface below it.
+ *
+ * A clicked section paints its heading over an empty body while its route resolves. The current
+ * section stays mounted underneath, invisible and inert, so its state survives if the navigation
+ * settles back on it.
+ */
+export function SettingsFrame({ children }: SettingsFrameProps) {
+ const pathname = usePathname()
+ const { pendingSection } = useSettingsNavigationState()
+ const pending = pendingSection ? resolveOrganizationSurfaceHeaderMeta(pendingSection) : null
+ const current = resolveOrganizationSurfaceHeaderMeta(pathname ?? '')
+ const title = pending?.title ?? current?.title ?? 'Settings'
+
+ return (
+
+
+
+
+
+
+ {pending && (
+
+
+
+
+
+ )}
+
+ {children}
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/settings/usage/events/page.tsx b/apps/sim/app/o/[organizationId]/settings/usage/events/page.tsx
index 9458349e21f..dfcb8b98034 100644
--- a/apps/sim/app/o/[organizationId]/settings/usage/events/page.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/usage/events/page.tsx
@@ -1,11 +1,10 @@
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { getOrganizationSettingsHref } from '@/components/settings/navigation'
-import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
import { getSession } from '@/lib/auth'
import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
-import { UsageEventsView } from '@/ee/organization-usage/components/usage-events-view'
+import { PlatformSettingsSectionHost } from '@/platform/app/(shell)/settings/_components/settings-section-host'
export const metadata: Metadata = { title: 'Usage events' }
@@ -34,11 +33,5 @@ export default async function OrganizationUsageEventsPage({
notFound()
}
- return (
-
-
-
-
-
- )
+ return
}
diff --git a/apps/sim/app/o/[organizationId]/viewer.ts b/apps/sim/app/o/[organizationId]/viewer.ts
new file mode 100644
index 00000000000..e7050337c2b
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/viewer.ts
@@ -0,0 +1,24 @@
+import type { SessionPrincipal } from '@sim/auth/principal'
+import { notFound, redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { getOrganizationSurfaceGate } from '@/lib/organizations/surface'
+import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
+
+/**
+ * Every organization page's gate. Signed out goes to sign-in and comes back to `callbackUrl`; signed
+ * in without access to this organization is a 404, so a private organization's existence never shows.
+ */
+export async function requireOrganizationViewer(organizationId: string, callbackUrl: string) {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ redirect(buildAuthCrossLink('/login', { callbackUrl, isInviteFlow: false }))
+ }
+ const surface = await getOrganizationSurfaceGate(organizationId, session.user.id)
+ if (!surface) notFound()
+ const principal: SessionPrincipal = {
+ kind: 'session',
+ userId: session.user.id,
+ sessionId: session.session.id,
+ }
+ return { userId: session.user.id, principal, surface }
+}
diff --git a/apps/sim/app/sim/[[...path]]/page.tsx b/apps/sim/app/sim/[[...path]]/page.tsx
index 698a4ffee5d..b690762facf 100644
--- a/apps/sim/app/sim/[[...path]]/page.tsx
+++ b/apps/sim/app/sim/[[...path]]/page.tsx
@@ -9,10 +9,10 @@ import {
type SimLinkResolution,
suggestSimLinks,
} from '@/lib/sim-links/application/use-cases'
+import { serializeSimLinkView } from '@/lib/sim-links/search-params'
import { parseTypedSimLink, simLinkNameSchema } from '@/lib/sim-links/validation'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { LogoShell } from '@/app/(landing)/components/logo-shell'
-import { serializeSimLinkView } from '@/app/o/[organizationId]/links/search-params'
export const metadata: Metadata = {
title: 'Link',
diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx
index edf3d280fcf..29c45fa2dd7 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx
@@ -13,10 +13,10 @@ import {
toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
-import type { BatchInvitationResult } from '@/lib/api/contracts/invitations'
import { useSession } from '@/lib/auth/client'
import { isEnterprise } from '@/lib/billing/plan-helpers'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
+import { buildInviteFailureMessage } from '@/lib/invitations/error-messages'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
@@ -47,40 +47,6 @@ const MEMBERSHIP_HINTS: Partial> = {
const EMPTY_WORKSPACE_IDS: string[] = []
-/** Distinct failure reasons listed in full before the remainder is counted instead. */
-const MAX_LISTED_FAILURE_REASONS = 3
-
-/**
- * Builds the submit error for a batch where some or all invitations failed.
- *
- * The server rejects per email and every reason names its own address, so the
- * reasons are listed rather than collapsed to the first one: inviting several
- * people who are all ineligible for the chosen membership is the common
- * multi-failure case, and reporting one of them hides who else needs attention.
- * Identical reasons are deduplicated, and beyond
- * {@link MAX_LISTED_FAILURE_REASONS} the tail is counted rather than listed so a
- * large batch cannot bury the modal in near-identical sentences.
- */
-export function buildInviteFailureMessage(
- failures: BatchInvitationResult['failed'],
- attemptedCount: number
-): string {
- if (failures.length === 1) return failures[0].error
-
- const headline =
- failures.length >= attemptedCount
- ? `None of the ${failures.length} invitations could be sent.`
- : `${failures.length} of ${attemptedCount} invitations could not be sent.`
-
- const reasons = [...new Set(failures.map((failure) => failure.error))]
- const listed = reasons.slice(0, MAX_LISTED_FAILURE_REASONS)
- const remaining = reasons.length - listed.length
-
- return [headline, ...listed, remaining > 0 ? `And ${remaining} more.` : null]
- .filter(Boolean)
- .join(' ')
-}
-
interface InviteModalProps {
open: boolean
onOpenChange: (open: boolean) => void
diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
index ed12d425be7..a70064d328b 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
@@ -22,7 +22,10 @@ import { useParams, useRouter } from 'next/navigation'
import { isApiClientError } from '@/lib/api/client/errors'
import { isLiveAssistantMessageId } from '@/lib/mothership/chat/live-message-id'
import { organizationRoutes } from '@/lib/navigation/paths'
-import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
+import {
+ useChatChrome,
+ useChatSurface,
+} from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
import { useFolderStore } from '@/stores/folders/store'
@@ -31,6 +34,24 @@ const ICON_CLASS = 'size-[14px]'
const BUTTON_CLASS =
'flex size-[26px] items-center pointer-coarse:size-[44px] justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-hidden'
+/** Everything a reply's action row shows and does, whichever design language draws it. */
+export interface MessageActionsModel {
+ canCopy: boolean
+ copied: boolean
+ onCopy: () => void
+ canRate: boolean
+ /** The rating whose feedback form is open. */
+ rating: 'up' | 'down' | null
+ onRate: (rating: 'up' | 'down') => void
+ onDismissRating: () => void
+ /** Sends the open rating with these details. Empty details send nothing. */
+ onSubmitRating: (details: string) => void
+ requestId?: string
+ canFork: boolean
+ forking: boolean
+ onFork: () => void
+}
+
interface MessageActionsProps {
content: string
getCopyContent?: () => string
@@ -59,6 +80,7 @@ export const MessageActions = memo(function MessageActions({
? { organizationId: params.organizationId }
: params.workspaceId
const { chatId, onNavigateToChat } = useChatSurface()
+ const { ResponseActionsComponent: ResponseActions } = useChatChrome()
const { copied, copy: copyMessage } = useCopyToClipboard({ resetMs: 1500 })
const [copiedRequestId, setCopiedRequestId] = useState(false)
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
@@ -105,9 +127,9 @@ export const MessageActions = memo(function MessageActions({
}
}
- const handleSubmitFeedback = () => {
+ const handleSubmitFeedback = (details = feedbackText) => {
if (!pendingFeedback || !chatId || !userQuery) return
- const text = feedbackText.trim()
+ const text = details.trim()
if (!text) {
setPendingFeedback(null)
setFeedbackText('')
@@ -166,6 +188,25 @@ export const MessageActions = memo(function MessageActions({
const canFork = Boolean(owner && chatId && messageId && !isLiveAssistantMessageId(messageId))
if (!canCopyContent && !canSubmitFeedback && !canFork) return null
+ if (ResponseActions) {
+ return (
+ handleModalClose(false)}
+ onSubmitRating={handleSubmitFeedback}
+ requestId={requestId}
+ canFork={canFork}
+ forking={forkChat.isPending}
+ onFork={handleFork}
+ />
+ )
+ }
+
return (
<>
@@ -265,7 +306,7 @@ export const MessageActions = memo(function MessageActions({
}
primaryAction={{
label: 'Submit',
- onClick: handleSubmitFeedback,
+ onClick: () => handleSubmitFeedback(),
}}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts
index b278c03e8e7..cd37a146c47 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts
@@ -4,8 +4,6 @@ export { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/re
export { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state'
export { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state'
export { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state'
-export { ResourceLoadError } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error'
export { ResourceNoResults } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results'
-export { SimLinksEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/sim-links-empty-state'
export { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state'
export { TestsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tests-empty-state'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error.tsx
deleted file mode 100644
index eaff2e66928..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Chip } from '@sim/emcn'
-
-interface ResourceLoadErrorProps {
- message: string
- onRetry: () => void
-}
-
-/** What a resource table shows when its first page failed to load. */
-export function ResourceLoadError({ message, onRetry }: ResourceLoadErrorProps) {
- return (
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/sim-links-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/sim-links-empty-state.tsx
deleted file mode 100644
index b0f2bcfbc48..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/sim-links-empty-state.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import { Chip, cn } from '@sim/emcn'
-import { EmptyState } from '@/components/empty-state/empty-state'
-import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline'
-import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask'
-
-/**
- * Skeleton ink — see the `INK` note in `tables-empty-state.tsx` for why not the surface
- * ramp. `mark` is the tables selection ring's opaque mix, so the held link reads as the
- * one drawn shortcut rather than more placeholder.
- */
-const INK = {
- title: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)',
- detail: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)',
- mark: 'color-mix(in srgb, var(--text-secondary) 46%, var(--bg))',
-} as const
-
-/** The logs feed's falloff — a link directory is the same repeating structure. */
-const DIRECTORY_FADE =
- '[-webkit-mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)] [mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)]'
-
-interface LinkRow {
- name: number
- destination: number
-}
-
-/** The held link leads, wholly inside the fade's opaque head, so its card never dissolves. */
-const ROWS: readonly LinkRow[] = [
- { name: 54, destination: 132 },
- { name: 70, destination: 108 },
- { name: 46, destination: 140 },
- { name: 62, destination: 118 },
-] as const
-
-const HELD_ROW = 0
-const ROW_PITCH = 38
-const PILL_X = 12
-const PILL_PADDING = 10
-
-/** A link directory: short names pointing at their destinations, one held in a card. */
-function SimLinksGraphic() {
- return (
-
- {ROWS.map((row, index) => {
- const y = 6 + index * ROW_PITCH
- const held = index === HELD_ROW
- const ink = held ? INK.mark : INK.title
- const arrowX = PILL_X + row.name + PILL_PADDING * 2 + 10
- return (
-
- {held ? (
-
- ) : null}
-
-
-
-
-
- )
- })}
-
- )
-}
-
-interface SimLinksEmptyStateProps {
- onCreate: () => void
-}
-
-/** Empty state for an organization with no Sim links yet. */
-export function SimLinksEmptyState({ onCreate }: SimLinksEmptyStateProps) {
- return (
-
}
- title='Links'
- description='Create short links like handbook for the pages your team opens most.'
- action={
-
- New link
-
- }
- />
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/mobile-title-slot.ts b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/mobile-title-slot.ts
new file mode 100644
index 00000000000..effb81841e4
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/mobile-title-slot.ts
@@ -0,0 +1,4 @@
+import { createContext } from 'react'
+
+/** Where a page may place its title in the phone top bar. Empty, it draws nothing. */
+export const MobileTitleSlot = createContext
(null)
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
index 5762d963c63..3b48b3441c9 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
@@ -14,6 +14,7 @@ import { Chip, cn } from '@sim/emcn'
import { ArrowLeft, ArrowRight, PanelLeft, X } from '@sim/emcn/icons'
import { usePathname } from 'next/navigation'
import { getDesktopBridge } from '@/lib/desktop'
+import { MobileTitleSlot } from '@/app/workspace/[workspaceId]/components/workspace-chrome/mobile-title-slot'
import { SidebarChromeProvider } from '@/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context'
import { useSidebarPeek } from '@/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek'
import { ChatPlanModal } from '@/app/workspace/[workspaceId]/home/components/chat-plan-modal'
@@ -24,6 +25,7 @@ import { useSidebarWidth } from '@/hooks/use-sidebar-width'
import { useFullscreenOriginStore } from '@/stores/fullscreen-origin/store'
import { useSearchModalStore } from '@/stores/modals/search/store'
import { useSidebarStore } from '@/stores/sidebar/store'
+import type { SidebarSurface } from '@/stores/sidebar/types'
const FULLSCREEN_SUFFIXES = ['/upgrade'] as const
@@ -69,9 +71,11 @@ interface WorkspaceChromeProps {
* The rail this chrome hosts. Rendered once inside the shell and never re-mounted
* across collapse, peek, or fullscreen; it reads collapse and peek state through
* {@link useSidebarChrome}. The workspace passes its own `Sidebar`; the organization
- * surface passes `OrganizationSidebar`.
+ * surface passes the design-system `Sidebar`.
*/
sidebar: ReactNode
+ /** Which rail's saved width and default this chrome sizes itself from. */
+ sidebarSurface?: SidebarSurface
/** Cookie-derived collapse state from the server layout; seeds the sidebar's first render. */
initialSidebarCollapsed?: boolean
}
@@ -167,6 +171,7 @@ function isFullscreenPath(pathname: string | null): boolean {
export function WorkspaceChrome({
children,
sidebar,
+ sidebarSurface = 'workspace',
initialSidebarCollapsed = false,
}: WorkspaceChromeProps) {
const pathname = usePathname()
@@ -200,6 +205,7 @@ export function WorkspaceChrome({
* only host where the hover-peek applies. `null` is the web app (icon rail).
*/
const [titleBarMode, setTitleBarMode] = useState(null)
+ const [mobileTitleSlot, setMobileTitleSlot] = useState(null)
/**
* The search palette is the one overlay reachable from the peeked card that renders
@@ -244,7 +250,7 @@ export function WorkspaceChrome({
isSearchModalOpen
)
- useSidebarWidth()
+ useSidebarWidth(sidebarSurface)
// Remember the last non-fullscreen page so a fullscreen route's Back control
// can return there, deterministically and for any trigger.
@@ -315,139 +321,142 @@ export function WorkspaceChrome({
}, [])
return (
-
- {!isFullscreen && (
-
-
- {mobileNavigationOpen ? 'Close navigation' : 'Navigation'}
-
-
- )}
-
+
{
- if (
- !mobileNavigationOpen ||
- event.metaKey ||
- event.ctrlKey ||
- event.shiftKey ||
- event.altKey
- ) {
- return
- }
- const link =
- event.target instanceof Element
- ? event.target.closest
('a[href]')
- : null
- if (link?.origin === window.location.origin && link.pathname === pathname) {
- setMobileNavigationPath(null)
- }
- }}
- suppressHydrationWarning
+ className='desktop-workspace-window-frame relative flex min-h-0 flex-1 flex-col md:flex-row'
+ data-sidebar-collapsed={isCollapsed || undefined}
>
+ {!isFullscreen && (
+
+
+ {mobileNavigationOpen ? 'Close navigation' : 'Navigation'}
+
+
+
+ )}
+ {
+ if (
+ !mobileNavigationOpen ||
+ event.metaKey ||
+ event.ctrlKey ||
+ event.shiftKey ||
+ event.altKey
+ ) {
+ return
+ }
+ const link =
+ event.target instanceof Element
+ ? event.target.closest('a[href]')
+ : null
+ if (link?.origin === window.location.origin && link.pathname === pathname) {
+ setMobileNavigationPath(null)
+ }
+ }}
+ suppressHydrationWarning
>
-
- {sidebar}
-
+
+ {sidebar}
+
+
-
-
- {children}
+
+ {children}
+
-
- {!isFullscreen && (
- /**
- * The hover region lives on this wrapper, not the button: `SidebarTooltip`
- * returns bare `children` when disabled, so suppressing the tooltip while
- * peeking swaps the element tree and remounts the button. A ref on the
- * button would go null mid-hover and retract the card; the wrapper never
- * unmounts, so it stays a stable anchor for the pointer hit-test.
- */
-
- )}
- {!isFullscreen && }
-
-
+
+
+
+
+
+ )}
+ {!isFullscreen && }
+
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/index.ts
index 18519acde4b..15363c0373f 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/index.ts
@@ -1 +1,2 @@
export { ShareModal } from './share-modal'
+export { type ShareDialogProps, useShareDialog } from './use-share-dialog'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
index 2fab30124ab..f2997d550c7 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
@@ -1,6 +1,5 @@
'use client'
-import { useState } from 'react'
import {
Chip,
ChipButtonGroup,
@@ -11,216 +10,48 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
- toast,
- useCopyToClipboard,
} from '@sim/emcn'
import { Check, Link, Send } from '@sim/emcn/icons'
-import { generateShortId } from '@sim/utils/id'
import { GeneratedPasswordInput } from '@/components/ui'
-import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares'
-import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
-import { validateAllowlistEntry } from '@/lib/messaging/email/validation'
+import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
import { SimLinkChip } from '@/app/o/[organizationId]/links/sim-link-shortcut'
-import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares'
-import { usePermissionConfig } from '@/hooks/use-permission-config'
-
-interface ShareModalProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- workspaceId: string
- fileId: string
- fileName: string
- /** Share state already known from the file row, used as the initial value to avoid flicker. */
- initialShare?: ShareRecord | null
-}
-
-const ACCESS_LABELS: Record = {
- public: 'Public',
- password: 'Password',
- email: 'Email',
- sso: 'SSO',
-}
-
-const PRIMARY_ACTION_LABELS = {
- share: { idle: 'Share', pending: 'Sharing...' },
- update: { idle: 'Update', pending: 'Updating...' },
- unshare: { idle: 'Unshare', pending: 'Unsharing...' },
-} as const
-
-const PRIMARY_ACTION_SUCCESS_MESSAGES = {
- share: 'File shared',
- update: 'Sharing updated',
- unshare: 'File unshared',
-} as const
-
-/** Stable identity so the emails field's reconcile effect no-ops while unset. */
-const EMPTY_EMAILS: string[] = []
-
-function savedMode(share: ShareRecord | null): ShareAuthType {
- return share?.authType ?? 'public'
-}
-
-export function ShareModal({
- open,
- onOpenChange,
- workspaceId,
- fileId,
- fileName,
- initialShare,
-}: ShareModalProps) {
- const {
- data: share,
- isError: isShareError,
- isFetchedAfterMount,
- } = useFileShare(workspaceId, fileId, { enabled: open })
- const { config: permissionConfig } = usePermissionConfig()
- const upsertShare = useUpsertFileShare()
- const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
- const { features } = useDeploymentShape()
-
- const shareReadReady = isFetchedAfterMount && !isShareError
- const saved = shareReadReady ? (share ?? null) : (share ?? initialShare ?? null)
- const savedAccessMode = savedMode(saved)
-
- const [draftMode, setDraftMode] = useState(null)
- const [draftPassword, setDraftPassword] = useState('')
- const [draftEmails, setDraftEmails] = useState(null)
- const [unshareConfirmOpen, setUnshareConfirmOpen] = useState(false)
- const effectiveMode = draftMode ?? savedAccessMode
- const effectiveEmails = draftEmails ?? saved?.allowedEmails ?? EMPTY_EMAILS
-
- const allowedAuthTypes = permissionConfig.allowedFileShareAuthTypes
- const isAuthTypeAllowed = (mode: ShareAuthType) =>
- allowedAuthTypes === null || allowedAuthTypes.includes(mode)
-
- const ssoEnabled = features.sso || savedAccessMode === 'sso'
- const candidateAuthTypes: ShareAuthType[] = [
- 'public',
- 'password',
- 'email',
- ...(ssoEnabled ? (['sso'] as const) : []),
- ]
- const accessModes = candidateAuthTypes.filter(
- (mode) => isAuthTypeAllowed(mode) || mode === savedAccessMode
- )
-
- const modeDisallowed = !isAuthTypeAllowed(effectiveMode)
- const enableBlockedByPolicy =
- (permissionConfig.disablePublicFileSharing && !saved?.isActive) || modeDisallowed
-
- const passwordMissing =
- effectiveMode === 'password' && !saved?.hasPassword && draftPassword.trim().length === 0
- const emailsMissing =
- (effectiveMode === 'email' || effectiveMode === 'sso') && effectiveEmails.length === 0
-
- const emailsDirty =
- draftEmails !== null &&
- JSON.stringify(draftEmails) !== JSON.stringify(saved?.allowedEmails ?? [])
- const isDirty =
- (draftMode !== null && draftMode !== savedAccessMode) ||
- (effectiveMode === 'password' && draftPassword.length > 0) ||
- ((effectiveMode === 'email' || effectiveMode === 'sso') && emailsDirty)
- const primaryAction = saved?.isActive ? (isDirty ? 'update' : 'unshare') : 'share'
- const isUnshareAction = primaryAction === 'unshare'
- const primaryActionPending = upsertShare.isPending || (isUnshareAction && unshareConfirmOpen)
- const primaryLabel =
- PRIMARY_ACTION_LABELS[primaryAction][primaryActionPending ? 'pending' : 'idle']
-
- const resetDraft = () => {
- setDraftMode(null)
- setDraftPassword('')
- setDraftEmails(null)
- }
-
- const handleClose = () => {
- setUnshareConfirmOpen(false)
- resetDraft()
- onOpenChange(false)
- }
-
- const submitPrimaryAction = () => {
- if (!shareReadReady || upsertShare.isPending) return
-
- const base = { workspaceId, fileId, token: saved ? undefined : generateShortId() }
- const vars = isUnshareAction
- ? { ...base, isActive: false as const }
- : effectiveMode === 'password'
- ? {
- ...base,
- isActive: true as const,
- authType: 'password' as const,
- password: draftPassword.trim() || undefined,
- }
- : effectiveMode === 'email' || effectiveMode === 'sso'
- ? {
- ...base,
- isActive: true as const,
- authType: effectiveMode,
- allowedEmails: effectiveEmails,
- }
- : { ...base, isActive: true as const, authType: 'public' as const }
-
- upsertShare.mutate(vars, {
- onSuccess: () => {
- toast.success(PRIMARY_ACTION_SUCCESS_MESSAGES[primaryAction])
- setUnshareConfirmOpen(false)
- resetDraft()
- },
- })
- }
-
- const handlePrimaryAction = () => {
- if (isUnshareAction) {
- setUnshareConfirmOpen(true)
- return
- }
- submitPrimaryAction()
- }
-
- const accessHint = (() => {
- if (isShareError) return 'Unable to load the current sharing settings. Close and try again.'
- if (modeDisallowed) return 'This sharing method is disabled by an administrator.'
- if (enableBlockedByPolicy)
- return 'Public sharing is disabled for this workspace by an administrator.'
- if (effectiveMode === 'password')
- return 'Anyone with the link and the password can view and download this file.'
- if (effectiveMode === 'email')
- return 'Only allowed emails can access this file after a one-time code.'
- if (effectiveMode === 'sso')
- return 'Only allowed emails signed in via SSO can access this file.'
- return saved?.isActive && !isDirty
- ? 'Anyone with the link can view and download this file.'
- : `${saved?.isActive ? 'Update' : 'Share'} to make this file accessible to anyone with the link.`
- })()
-
+import {
+ type ShareDialogProps,
+ useShareDialog,
+} from '@/app/workspace/[workspaceId]/files/components/share-modal/use-share-dialog'
+
+export function ShareModal(props: ShareDialogProps) {
+ const { open, fileName } = props
+ const dialog = useShareDialog(props)
+ const { saved } = dialog
return (
<>
-
+
Share file
-
+
setDraftMode(value as ShareAuthType)}
+ value={dialog.mode}
+ onValueChange={(value) => dialog.setMode(value as ShareAuthType)}
aria-label='File access'
- disabled={upsertShare.isPending}
+ disabled={dialog.pending}
>
- {accessModes.map((mode) => (
-
- {ACCESS_LABELS[mode]}
+ {dialog.accessModes.map(({ value, label }) => (
+
+ {label}
))}
- {effectiveMode === 'password' ? (
+ {dialog.mode === 'password' ? (
) : null}
- {effectiveMode === 'email' || effectiveMode === 'sso' ? (
+ {dialog.mode === 'email' || dialog.mode === 'sso' ? (
) : null}
copy(saved.url)}>
- {copied ? 'Copied!' : 'Copy link'}
+ dialog.copy(saved.url)}
+ >
+ {dialog.copied ? 'Copied!' : 'Copy link'}
),
},
@@ -274,25 +108,22 @@ export function ShareModal({
: undefined
}
primaryAction={{
- label: primaryLabel,
- onClick: handlePrimaryAction,
- variant: isUnshareAction ? 'destructive' : 'primary',
- disabled:
- upsertShare.isPending ||
- !shareReadReady ||
- (!isUnshareAction && (passwordMissing || emailsMissing || enableBlockedByPolicy)),
+ label: dialog.primaryLabel,
+ onClick: dialog.runPrimaryAction,
+ variant: dialog.isUnshareAction ? 'destructive' : 'primary',
+ disabled: dialog.primaryDisabled,
}}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/use-share-dialog.ts b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/use-share-dialog.ts
new file mode 100644
index 00000000000..bdaec7e8f0f
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/use-share-dialog.ts
@@ -0,0 +1,213 @@
+'use client'
+
+import { useState } from 'react'
+import { toast, useCopyToClipboard } from '@sim/emcn'
+import { generateShortId } from '@sim/utils/id'
+import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares'
+import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
+import { validateAllowlistEntry } from '@/lib/messaging/email/validation'
+import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares'
+import { usePermissionConfig } from '@/hooks/use-permission-config'
+
+export interface ShareDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ workspaceId: string
+ fileId: string
+ fileName: string
+ /** Share state already known from the file row, used as the initial value to avoid flicker. */
+ initialShare?: ShareRecord | null
+}
+
+const ACCESS_LABELS: Record = {
+ public: 'Public',
+ password: 'Password',
+ email: 'Email',
+ sso: 'SSO',
+}
+
+const PRIMARY_ACTION_LABELS = {
+ share: { idle: 'Share', pending: 'Sharing...' },
+ update: { idle: 'Update', pending: 'Updating...' },
+ unshare: { idle: 'Unshare', pending: 'Unsharing...' },
+} as const
+
+const PRIMARY_ACTION_SUCCESS_MESSAGES = {
+ share: 'File shared',
+ update: 'Sharing updated',
+ unshare: 'File unshared',
+} as const
+
+/** Stable identity so the emails field's reconcile effect no-ops while unset. */
+const EMPTY_EMAILS: string[] = []
+
+function savedMode(share: ShareRecord | null): ShareAuthType {
+ return share?.authType ?? 'public'
+}
+
+/** A file share's rules and requests, for whichever dialog draws them. */
+export function useShareDialog({
+ open,
+ onOpenChange,
+ workspaceId,
+ fileId,
+ initialShare,
+}: Omit) {
+ const {
+ data: share,
+ isError: isShareError,
+ isFetchedAfterMount,
+ } = useFileShare(workspaceId, fileId, { enabled: open })
+ const { config: permissionConfig } = usePermissionConfig()
+ const upsertShare = useUpsertFileShare()
+ const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
+ const { features } = useDeploymentShape()
+
+ const shareReadReady = isFetchedAfterMount && !isShareError
+ const saved = shareReadReady ? (share ?? null) : (share ?? initialShare ?? null)
+ const savedAccessMode = savedMode(saved)
+
+ const [draftMode, setDraftMode] = useState(null)
+ const [draftPassword, setDraftPassword] = useState('')
+ const [draftEmails, setDraftEmails] = useState(null)
+ const [unshareConfirmOpen, setUnshareConfirmOpen] = useState(false)
+ const effectiveMode = draftMode ?? savedAccessMode
+ const effectiveEmails = draftEmails ?? saved?.allowedEmails ?? EMPTY_EMAILS
+
+ const allowedAuthTypes = permissionConfig.allowedFileShareAuthTypes
+ const isAuthTypeAllowed = (mode: ShareAuthType) =>
+ allowedAuthTypes === null || allowedAuthTypes.includes(mode)
+
+ const ssoEnabled = features.sso || savedAccessMode === 'sso'
+ const candidateAuthTypes: ShareAuthType[] = [
+ 'public',
+ 'password',
+ 'email',
+ ...(ssoEnabled ? (['sso'] as const) : []),
+ ]
+ const accessModes = candidateAuthTypes.filter(
+ (mode) => isAuthTypeAllowed(mode) || mode === savedAccessMode
+ )
+
+ const modeDisallowed = !isAuthTypeAllowed(effectiveMode)
+ const enableBlockedByPolicy =
+ (permissionConfig.disablePublicFileSharing && !saved?.isActive) || modeDisallowed
+
+ const passwordMissing =
+ effectiveMode === 'password' && !saved?.hasPassword && draftPassword.trim().length === 0
+ const emailsMissing =
+ (effectiveMode === 'email' || effectiveMode === 'sso') && effectiveEmails.length === 0
+
+ const emailsDirty =
+ draftEmails !== null &&
+ JSON.stringify(draftEmails) !== JSON.stringify(saved?.allowedEmails ?? [])
+ const isDirty =
+ (draftMode !== null && draftMode !== savedAccessMode) ||
+ (effectiveMode === 'password' && draftPassword.length > 0) ||
+ ((effectiveMode === 'email' || effectiveMode === 'sso') && emailsDirty)
+ const primaryAction = saved?.isActive ? (isDirty ? 'update' : 'unshare') : 'share'
+ const isUnshareAction = primaryAction === 'unshare'
+ const primaryActionPending = upsertShare.isPending || (isUnshareAction && unshareConfirmOpen)
+ const primaryLabel =
+ PRIMARY_ACTION_LABELS[primaryAction][primaryActionPending ? 'pending' : 'idle']
+
+ const resetDraft = () => {
+ setDraftMode(null)
+ setDraftPassword('')
+ setDraftEmails(null)
+ }
+
+ const handleClose = () => {
+ setUnshareConfirmOpen(false)
+ resetDraft()
+ onOpenChange(false)
+ }
+
+ /** Settles with the save, for a dialog to hold on. A failure stays on `upsertShare.error`. */
+ const submitPrimaryAction = async () => {
+ if (!shareReadReady || upsertShare.isPending) return
+
+ const base = { workspaceId, fileId, token: saved ? undefined : generateShortId() }
+ const vars = isUnshareAction
+ ? { ...base, isActive: false as const }
+ : effectiveMode === 'password'
+ ? {
+ ...base,
+ isActive: true as const,
+ authType: 'password' as const,
+ password: draftPassword.trim() || undefined,
+ }
+ : effectiveMode === 'email' || effectiveMode === 'sso'
+ ? {
+ ...base,
+ isActive: true as const,
+ authType: effectiveMode,
+ allowedEmails: effectiveEmails,
+ }
+ : { ...base, isActive: true as const, authType: 'public' as const }
+
+ try {
+ await upsertShare.mutateAsync(vars)
+ } catch {
+ return
+ }
+ toast.success(PRIMARY_ACTION_SUCCESS_MESSAGES[primaryAction])
+ setUnshareConfirmOpen(false)
+ resetDraft()
+ }
+
+ const handlePrimaryAction = async () => {
+ if (isUnshareAction) {
+ setUnshareConfirmOpen(true)
+ return
+ }
+ await submitPrimaryAction()
+ }
+
+ const accessHint = (() => {
+ if (isShareError) return 'Unable to load the current sharing settings. Close and try again.'
+ if (modeDisallowed) return 'This sharing method is disabled by an administrator.'
+ if (enableBlockedByPolicy)
+ return 'Public sharing is disabled for this workspace by an administrator.'
+ if (effectiveMode === 'password')
+ return 'Anyone with the link and the password can view and download this file.'
+ if (effectiveMode === 'email')
+ return 'Only allowed emails can access this file after a one-time code.'
+ if (effectiveMode === 'sso')
+ return 'Only allowed emails signed in via SSO can access this file.'
+ return saved?.isActive && !isDirty
+ ? 'Anyone with the link can view and download this file.'
+ : `${saved?.isActive ? 'Update' : 'Share'} to make this file accessible to anyone with the link.`
+ })()
+
+ const primaryDisabled =
+ upsertShare.isPending ||
+ !shareReadReady ||
+ (!isUnshareAction && (passwordMissing || emailsMissing || enableBlockedByPolicy))
+
+ return {
+ saved,
+ accessHint,
+ accessModes: accessModes.map((value) => ({ value, label: ACCESS_LABELS[value] })),
+ mode: effectiveMode,
+ setMode: setDraftMode,
+ password: draftPassword,
+ setPassword: setDraftPassword,
+ emails: effectiveEmails,
+ setEmails: setDraftEmails,
+ validateEmail: validateAllowlistEntry,
+ /** The last save's failure, which a dialog over the page's toasts has to show itself. */
+ error: upsertShare.error?.message ?? null,
+ pending: upsertShare.isPending,
+ primaryLabel,
+ primaryDisabled,
+ isUnshareAction,
+ runPrimaryAction: handlePrimaryAction,
+ copied,
+ copy,
+ unshareConfirmOpen,
+ setUnshareConfirmOpen,
+ confirmUnshare: submitPrimaryAction,
+ close: handleClose,
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
index 76aad2ac614..8381d8a7420 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -38,11 +38,8 @@ import {
} from '@/lib/uploads/client/download'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
-import {
- formatFileSize,
- getMimeTypeFromExtension,
- isArchiveFileName,
-} from '@/lib/uploads/utils/file'
+import { getMimeTypeFromExtension, isArchiveFileName } from '@/lib/uploads/utils/file'
+import { formatFileSize } from '@/lib/uploads/utils/file-size'
import {
fileBrowserTypeLabel,
isFileBrowserSize,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx
index 23ceefc8305..dff419ad152 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx
@@ -18,6 +18,8 @@ interface ChatPanelLayoutProps {
collapsed: boolean
label: string
activityCount?: number
+ /** Removes the collapse control, for a host whose panel has nothing to open yet. */
+ toggleHidden?: boolean
onToggle: () => void
onResize: PointerEventHandler
onResizeKeyDown: KeyboardEventHandler
@@ -33,6 +35,7 @@ export function ChatPanelLayout({
collapsed,
label,
activityCount = 0,
+ toggleHidden = false,
onToggle,
onResize,
onResizeKeyDown,
@@ -74,41 +77,43 @@ export function ChatPanelLayout({
)}
{panel}
-
-
- {toggleLabel}
- {!collapsed && Back to chat }
-
- {!collapsed && (
-
- )}
-
+ {toggleLabel}
+ {!collapsed && Back to chat }
+
+ {!collapsed && (
+
)}
- />
- {collapsed && activityCount > 0 && (
-
- )}
-
-
-
+ {collapsed && activityCount > 0 && (
+
+ )}
+
+
+
+ )}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx
index 3addb7479ce..eb1f55f11d6 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx
@@ -1,8 +1,10 @@
'use client'
import { lazy, type ReactNode, Suspense, useCallback } from 'react'
+import { cn } from '@sim/emcn'
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
import { ChatPanelLayout } from '@/app/workspace/[workspaceId]/home/components/chat-panel-layout'
+import { useChatChrome } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import { MothershipResourcesProvider } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context'
import { useBrowserTabResources } from '@/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources'
import type { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
@@ -23,6 +25,8 @@ interface ChatResourcePanelProps {
panel: ReturnType
children: ReactNode
header?: ReactNode
+ /** Leaves out the panel toggle while there is no resource to show and the panel is closed. */
+ hideToggleUntilResource?: boolean
onSummarize?: (message: string, filters: WorkspaceSearchFilters) => void
}
@@ -35,8 +39,10 @@ export function ChatResourcePanel({
panel,
children,
header,
+ hideToggleUntilResource = false,
onSummarize,
}: ChatResourcePanelProps) {
+ const { panelClassName } = useChatChrome()
useBrowserTabResources(panel.desktopTabResourceOptions)
useTerminalTabResources(panel.desktopTabResourceOptions)
const {
@@ -77,6 +83,7 @@ export function ChatResourcePanel({
collapsed={isResourceCollapsed}
label='resource view'
activityCount={resourceActivityIds.size}
+ toggleHidden={hideToggleUntilResource && isResourceCollapsed && resources.length === 0}
onToggle={isResourceCollapsed ? expandResource : collapseResource}
onResize={handleResourceResizePointerDown}
onResizeKeyDown={handleResourceResizeKeyDown}
@@ -106,7 +113,7 @@ export function ChatResourcePanel({
isAgentResponding={isSending}
onSummarize={summarize}
onUserInteraction={handleResourceInteraction}
- className={skipResourceTransition ? 'transition-none!' : undefined}
+ className={cn(skipResourceTransition && 'transition-none!', panelClassName)}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx
index 1ca8f77beb9..bac187bdb04 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx
@@ -11,10 +11,48 @@ import {
useRef,
} from 'react'
import { noop } from '@sim/utils/helpers'
+import { inter } from '@/app/_styles/fonts/inter/inter'
+import type { MessageActionsModel } from '@/app/workspace/[workspaceId]/components/message-actions/message-actions'
+import type { ShareDialogProps } from '@/app/workspace/[workspaceId]/files/components/share-modal'
+import type { PageContextDialogProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/page-context'
import type { SearchIntegrationConnectionProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection'
import type { WorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/types'
import type { ChatContext } from '@/stores/panel'
+/**
+ * How a host draws the shared chat: its typeface, its class names and the parts it replaces. Set once
+ * around the whole chat, since context also reaches what the chat portals out of its own tree. A host
+ * that sets nothing gets the workspace's own look.
+ */
+export interface ChatChrome {
+ fontClassName: string
+ panelClassName?: string
+ tabsClassName?: string
+ sourcesClassName?: string
+ userBubbleClassName?: string
+ /** The reply's action row: copy, votes, fork, and sources. */
+ responseActionsClassName?: string
+ sourcesPopoverClassName?: string
+ /** Holds the transcript's place while the history loads. */
+ loadingTranscript?: ReactNode
+ /** Holds the composer's place while the history loads. */
+ loadingComposer?: ReactNode
+ /** Draws a reply's action row in place of the default one. `MessageActions` keeps its behavior. */
+ ResponseActionsComponent?: ComponentType
+ /** Draws the open page-context dialog in place of the default one. */
+ PageContextDialogComponent?: ComponentType
+ /** Draws the share dialog in place of the default one. `useShareDialog` keeps its behavior. */
+ ShareDialogComponent?: ComponentType
+}
+
+const ChatChromeContext = createContext({ fontClassName: inter.className })
+
+export const ChatChromeProvider = ChatChromeContext.Provider
+
+export function useChatChrome() {
+ return useContext(ChatChromeContext)
+}
+
/**
* Identity and interaction callbacks shared across a Mothership chat surface
* (home conversation view, home initial view, copilot panel). Carried via
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/index.ts
index a9bd0ecd91b..e670e8747fb 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/index.ts
@@ -1 +1,7 @@
-export { ChatSurfaceProvider, useChatSurface } from './chat-surface-context'
+export {
+ type ChatChrome,
+ ChatChromeProvider,
+ ChatSurfaceProvider,
+ useChatChrome,
+ useChatSurface,
+} from './chat-surface-context'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
index e2125fc0f5e..9437876eaa1 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
@@ -1,27 +1,19 @@
'use client'
-import { useEffect, useMemo, useState } from 'react'
import { Chip, ChipLink } from '@sim/emcn'
-import { useQueryStates } from 'nuqs'
import { ActivityStatus } from '@/components/ui/activity-status'
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
import { useSession } from '@/lib/auth/client'
-import { type ResourceScope, resourceScopeKey } from '@/lib/core/resource-scope'
-import { organizationRoutes } from '@/lib/navigation/paths'
+import type { ResourceScope } from '@/lib/core/resource-scope'
import { SearchFilters } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-filters'
+import { useLiveSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/use-live-search-results'
import {
- groupResultsByDocument,
handleResultsKeyDown,
+ liveSearchResultsKey,
type SearchResultsProps,
toSource,
} from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/utils'
import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
-import {
- resourceUrlKeys,
- searchFilterParsers,
- searchFiltersFromParams,
-} from '@/app/workspace/[workspaceId]/home/search-params'
-import { useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge'
type KnowledgeSearchResultsProps = (
| { workspaceId: string; scope?: never }
@@ -42,7 +34,6 @@ export function KnowledgeSearchResults({
nativeQueries,
reuseFreshResult,
onSummarize,
- onSearchChange,
}: KnowledgeSearchResultsProps) {
const scope: ResourceScope = suppliedScope ?? { kind: 'workspace', workspaceId: workspaceId! }
const { data: session } = useSession()
@@ -50,7 +41,7 @@ export function KnowledgeSearchResults({
const Results = LiveSearchResults
return (
)
}
/** Live results do not mount index or sync-status queries. */
-function LiveSearchResults({
- scope,
- query,
- suppliedFilters,
- topK,
- nativeQueries,
- reuseFreshResult,
- onSummarize,
- onSearchChange,
-}: SearchResultsProps) {
- const [hasShownFilters, setHasShownFilters] = useState(false)
- const [searchedAt] = useState(() => Date.now())
- const [params] = useQueryStates(searchFilterParsers, resourceUrlKeys)
- const filters = useMemo(
- () => suppliedFilters ?? searchFiltersFromParams(params, searchedAt),
- [suppliedFilters, params.source, params.updated, params.from, params.to, searchedAt]
- )
- const awaitingRange =
- !suppliedFilters && params.updated === 'custom' && !(params.from && params.to)
- const { data, isPending, isFetching, isError, refetch } = useWorkspaceKnowledgeSearch(
- scope,
- awaitingRange ? '' : query,
- filters,
- topK ?? 20,
- { nativeQueries, reuseFreshResult }
- )
- useEffect(() => {
- onSearchChange?.({
- scope,
- query,
- filters,
- ...(topK ? { topK } : {}),
- ...(nativeQueries ? { nativeQueries } : {}),
- })
- }, [
- scope.kind,
- scope.kind === 'organization' ? scope.organizationId : scope.workspaceId,
- query,
- filters,
- topK,
- nativeQueries,
- onSearchChange,
- ])
- const showFilters = hasShownFilters || data !== undefined || awaitingRange || isError
- if (showFilters && !hasShownFilters) setHasShownFilters(true)
- const documents = groupResultsByDocument(data?.results ?? [])
- const accounts = data?.live?.accounts ?? []
- const sources = [
- ...new Set([
- ...accounts.map((account) => account.provider),
- ...(filters.source ? [filters.source] : []),
- ]),
- ]
+function LiveSearchResults(props: SearchResultsProps) {
+ const { scope, query } = props
+ const {
+ awaitingRange,
+ isPending,
+ isFetching,
+ isError,
+ refetch,
+ documents,
+ showFilterControls,
+ sourceTypes,
+ noResults,
+ connectAppsHref,
+ someAppsUnavailable,
+ coverageIncomplete,
+ summarizerFor,
+ } = useLiveSearchResults(props)
return (
{!awaitingRange && isFetching && (
@@ -140,42 +94,26 @@ function LiveSearchResults({
)}
)}
- {!suppliedFilters && showFilters && }
- {!awaitingRange &&
- !isPending &&
- !isFetching &&
- !isError &&
- data?.retrieval.status === 'complete' &&
- documents.length === 0 && (
-
- Search found no results.
-
- )}
- {!isFetching && data?.live && accounts.length === 0 && (
+ {showFilterControls && }
+ {noResults && (
+ Search found no results.
+ )}
+ {connectAppsHref && (
No connected apps are available for this search.
-
+
Connect apps
)}
- {accounts.some((account) =>
- ['unavailable', 'timeout', 'rate_limited', 'reconnect'].includes(account.status)
- ) && (
+ {someAppsUnavailable && (
Some apps couldn’t be searched. Showing available results.
)}
- {!data?.live && data?.retrieval.status === 'partial' && (
+ {coverageIncomplete && (
Coverage is incomplete. Narrow the query or ask Assistant to refine it.
@@ -192,15 +130,7 @@ function LiveSearchResults({
key={result.documentId}
source={toSource(result, query, scope)}
query={query}
- onSummarize={
- isFetching || !onSummarize
- ? undefined
- : (cited) =>
- onSummarize(`Summarize "${cited.title ?? cited.url}"`, {
- ...filters,
- documentIds: [result.documentId],
- })
- }
+ onSummarize={summarizerFor(result.documentId)}
/>
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-filters.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-filters.tsx
index c1d28baf693..8493dd08cfe 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-filters.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-filters.tsx
@@ -1,19 +1,13 @@
import { ChipDatePicker, ChipDropdown } from '@sim/emcn'
-import { useQueryStates } from 'nuqs'
-import { connectorDisplayName } from '@/lib/sim-search/connectors'
-import {
- resourceUrlKeys,
- searchFilterParsers,
- UPDATED_WINDOWS,
-} from '@/app/workspace/[workspaceId]/home/search-params'
+import { useSearchFilterControls } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/use-search-filter-controls'
interface SearchFiltersProps {
sourceTypes: readonly string[]
}
-/** Shared refinements for indexed and live search, backed by the same URL parameters. */
+/** The refinement controls in the app's chip chrome. */
export function SearchFilters({ sourceTypes }: SearchFiltersProps) {
- const [filters, setFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys)
+ const { source, updated, range } = useSearchFilterControls(sourceTypes)
return (
({
- value: type,
- label: type === 'upload' ? 'Uploads' : connectorDisplayName(type),
- })),
- ]}
- onChange={(source) => void setFilters({ source: source || null })}
+ value={source.value}
+ options={source.options}
+ onChange={source.onChange}
/>
window.id === filters.updated)?.label ?? 'Any time'}`}
+ aria-label={updated.label}
matchTriggerWidth={false}
- value={filters.updated}
- options={UPDATED_WINDOWS.map((window) => ({ value: window.id, label: window.label }))}
- onChange={(value) => {
- const window = UPDATED_WINDOWS.find((entry) => entry.id === value)
- if (window)
- void setFilters(
- window.id === 'custom'
- ? { updated: window.id }
- : { updated: window.id, from: null, to: null }
- )
- }}
+ value={updated.value}
+ options={updated.options}
+ onChange={updated.onChange}
/>
- {filters.updated === 'custom' && (
+ {range && (
- void setFilters({ from: new Date(start), to: new Date(end) })
- }
- onClear={() => void setFilters({ updated: 'any', from: null, to: null })}
+ startDate={range.start}
+ endDate={range.end}
+ onRangeChange={range.onChange}
+ onClear={range.onClear}
/>
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx
index 74b4ca76313..7ada09dd894 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx
@@ -22,41 +22,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
userId: 'reader',
- summarize: vi.fn(),
urlUpdate: vi.fn(),
}))
vi.mock('@/lib/auth/client', () => authClientMock)
vi.mock('@/lib/api/client/request', () => apiClientRequestMock)
vi.mock('next/navigation', () => nextNavigationMock)
-vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => organizationProviderMock)
+vi.mock('@/lib/organizations/provider', () => organizationProviderMock)
vi.mock('@/hooks/use-speech-to-text', () => ({
useSpeechToText: () => ({ isSupported: false }),
}))
vi.mock('@/hooks/queries/kb/connectors', () => kbConnectorsQueriesMock)
-vi.mock(
- '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card',
- () => ({
- SourceCard: ({
- source,
- onSummarize,
- }: {
- source: { title: string; url: string }
- onSummarize?: (source: { title: string; url: string }) => void
- }) => (
-
-
- {source.title}
-
-
Copy link
- {onSummarize && (
-
onSummarize(source)}>
- Summarize
-
- )}
-
- ),
- })
-)
import type {
WorkspaceKnowledgeSearchBody,
@@ -67,19 +42,17 @@ import {
resolveDeploymentShape,
seedDeploymentShape,
} from '@/lib/core/config/deployment-shape'
-import type { ResourceScope } from '@/lib/core/resource-scope'
-import { SearchLandingHistory } from '@/app/o/[organizationId]/components/search-landing-history'
-import { OrganizationSearch } from '@/app/o/[organizationId]/search/search'
-import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results'
-import { searchHistoryKeys } from '@/hooks/queries/search-history'
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
+import { searchHistoryKeys } from '@/hooks/queries/utils/search-history-keys'
+import { SearchHistory } from '@/platform/app/(shell)/search/_components/search-surface/search-history'
+import { OrganizationSearchSurface } from '@/platform/app/(shell)/search/_components/search-surface/search-surface'
import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
nextNavigationMockFns.mockUsePathname.mockReturnValue('/o/organization/search')
authClientMockFns.mockUseSession.mockImplementation(() => ({
data: { user: { id: mocks.userId } },
}))
-organizationProviderMockFns.mockUseOrganizationContext.mockImplementation(() => ({
+organizationProviderMockFns.mockUseOrganization.mockImplementation(() => ({
organization: { id: 'organization', name: 'Acme' },
searchAccess: { memberScoped: true },
}))
@@ -137,36 +110,12 @@ afterEach(() => {
vi.useRealTimers()
})
-async function render({
- scope = { kind: 'organization', organizationId: 'organization' },
- query = 'launch',
- params = '',
- organizationPage = false,
- filters,
- topK,
-}: {
- scope?: ResourceScope
- query?: string
- params?: string
- organizationPage?: boolean
- filters?: import('@/lib/api/contracts/knowledge').WorkspaceSearchFilters
- topK?: number
-} = {}) {
+async function render(params = '') {
await act(async () => {
root.render(
- {organizationPage ? (
-
- ) : (
-
- )}
+
)
@@ -215,7 +164,7 @@ describe('live search submission feedback', () => {
async (draft) => {
const shape = resolveDeploymentShape()
seedDeploymentShape({ ...shape, features: shape.features })
- await render({ organizationPage: true, params: '?q=launch' })
+ await render('?q=launch')
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
@@ -267,16 +216,12 @@ describe('live search submission feedback', () => {
it('acknowledges the submitted query before exposing refinement controls', async () => {
const shape = resolveDeploymentShape()
seedDeploymentShape({ ...shape, features: shape.features })
- await render({ organizationPage: true, params: '?q=launch' })
+ await render('?q=launch')
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(requests).toHaveLength(1)
- expect(
- [...container.querySelectorAll('button')].some(
- (element) => element.textContent === 'All sources'
- )
- ).toBe(false)
+ expect(container.querySelector('[role="group"][aria-label="Search filters"]')).toBeNull()
expect(
container.querySelector('button[aria-label="Stop search"]')?.disabled
).toBe(false)
@@ -326,9 +271,9 @@ it('removes cached private history when access is denied during revalidation', a
await act(async () => {
root.render(
- {}}>
+ {}}>
-
+
)
await vi.advanceTimersByTimeAsync(1)
@@ -352,7 +297,7 @@ it('keeps an unsent draft when a different recent search is opened', async () =>
sources: [],
queries: [{ query: 'launch', searchedAt: new Date().toISOString() }],
})
- await render({ organizationPage: true })
+ await render()
const recent = [...container.querySelectorAll('button')].find(
(button) => button.textContent === 'launch'
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/use-live-search-results.ts b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/use-live-search-results.ts
new file mode 100644
index 00000000000..b35ec488274
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/use-live-search-results.ts
@@ -0,0 +1,89 @@
+import { useState } from 'react'
+import { useQueryStates } from 'nuqs'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import {
+ groupResultsByDocument,
+ type SearchResultsProps,
+} from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/utils'
+import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+import {
+ resourceUrlKeys,
+ searchFilterParsers,
+ searchFiltersFromParams,
+} from '@/app/workspace/[workspaceId]/home/search-params'
+import { useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge'
+
+const UNAVAILABLE_ACCOUNT_STATUSES = ['unavailable', 'timeout', 'rate_limited', 'reconnect']
+
+/**
+ * What a live search shows and when. Mount it under a key from `liveSearchResultsKey`: the rolling
+ * date anchor and the revealed filters belong to one query in one access scope.
+ */
+export function useLiveSearchResults({
+ scope,
+ query,
+ suppliedFilters,
+ topK,
+ nativeQueries,
+ reuseFreshResult,
+ onSummarize,
+}: SearchResultsProps) {
+ const [hasShownFilters, setHasShownFilters] = useState(false)
+ const [searchedAt] = useState(() => Date.now())
+ const [params] = useQueryStates(searchFilterParsers, resourceUrlKeys)
+ const filters = suppliedFilters ?? searchFiltersFromParams(params, searchedAt)
+ const awaitingRange =
+ !suppliedFilters && params.updated === 'custom' && !(params.from && params.to)
+ const { data, isPending, isFetching, isError, refetch } = useWorkspaceKnowledgeSearch(
+ scope,
+ awaitingRange ? '' : query,
+ filters,
+ topK ?? 20,
+ { nativeQueries, reuseFreshResult }
+ )
+ const showFilters = hasShownFilters || data !== undefined || awaitingRange || isError
+ if (showFilters && !hasShownFilters) setHasShownFilters(true)
+ const documents = groupResultsByDocument(data?.results ?? [])
+ const accounts = data?.live?.accounts ?? []
+ return {
+ awaitingRange,
+ isPending,
+ isFetching,
+ isError,
+ refetch,
+ documents,
+ showFilterControls: !suppliedFilters && showFilters,
+ sourceTypes: [
+ ...new Set([
+ ...accounts.map((account) => account.provider),
+ ...(filters.source ? [filters.source] : []),
+ ]),
+ ],
+ noResults:
+ !awaitingRange &&
+ !isPending &&
+ !isFetching &&
+ !isError &&
+ data?.retrieval.status === 'complete' &&
+ documents.length === 0,
+ connectAppsHref:
+ !isFetching && data?.live && accounts.length === 0
+ ? scope.kind === 'organization'
+ ? organizationRoutes(scope.organizationId).integrations
+ : `/workspace/${scope.workspaceId}/integrations`
+ : null,
+ someAppsUnavailable: accounts.some((account) =>
+ UNAVAILABLE_ACCOUNT_STATUSES.includes(account.status)
+ ),
+ coverageIncomplete: !data?.live && data?.retrieval.status === 'partial',
+ /** Summarize's hand-off for one document: scoped to it, and absent while results may change. */
+ summarizerFor: (documentId: string) =>
+ isFetching || !onSummarize
+ ? undefined
+ : (cited: SourceTagData) =>
+ onSummarize(`Summarize "${cited.title ?? cited.url}"`, {
+ ...filters,
+ documentIds: [documentId],
+ }),
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/use-search-filter-controls.ts b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/use-search-filter-controls.ts
new file mode 100644
index 00000000000..99c4db35d75
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/use-search-filter-controls.ts
@@ -0,0 +1,55 @@
+import { useQueryStates } from 'nuqs'
+import { connectorDisplayName } from '@/lib/sim-search/connectors'
+import {
+ resourceUrlKeys,
+ searchFilterParsers,
+ UPDATED_WINDOWS,
+} from '@/app/workspace/[workspaceId]/home/search-params'
+
+/** A picker can't hold an empty string, so "every source" has a value of its own. */
+const ALL_SOURCES = '__all_sources__'
+
+function sourceOptionLabel(type: string): string {
+ return type === 'upload' ? 'Uploads' : connectorDisplayName(type)
+}
+
+/** Shared refinements for indexed and live search, backed by the same URL parameters. */
+export function useSearchFilterControls(sourceTypes: readonly string[]) {
+ const [filters, setFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys)
+ return {
+ source: {
+ value: filters.source ?? ALL_SOURCES,
+ label: `Filter by source: ${filters.source ? sourceOptionLabel(filters.source) : 'All sources'}`,
+ options: [
+ { value: ALL_SOURCES, label: 'All sources' },
+ ...sourceTypes.map((type) => ({ value: type, label: sourceOptionLabel(type) })),
+ ],
+ onChange: (source: string) =>
+ void setFilters({ source: source === ALL_SOURCES ? null : source }),
+ },
+ updated: {
+ value: filters.updated,
+ label: `Filter by date: ${UPDATED_WINDOWS.find((window) => window.id === filters.updated)?.label ?? 'Any time'}`,
+ options: UPDATED_WINDOWS.map((window) => ({ value: window.id, label: window.label })),
+ onChange: (value: string) => {
+ const window = UPDATED_WINDOWS.find((entry) => entry.id === value)
+ if (window)
+ void setFilters(
+ window.id === 'custom'
+ ? { updated: window.id }
+ : { updated: window.id, from: null, to: null }
+ )
+ },
+ },
+ range:
+ filters.updated === 'custom'
+ ? {
+ start: filters.from?.toISOString().slice(0, 10),
+ end: filters.to?.toISOString().slice(0, 10),
+ onChange: (start: string, end: string) =>
+ void setFilters({ from: new Date(start), to: new Date(end) }),
+ onClear: () => void setFilters({ updated: 'any', from: null, to: null }),
+ }
+ : null,
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/utils.ts
index 6c20582454f..f95930ae20e 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/utils.ts
@@ -2,7 +2,7 @@ import type {
WorkspaceKnowledgeSearchResult,
WorkspaceSearchFilters,
} from '@/lib/api/contracts/knowledge'
-import type { ResourceScope } from '@/lib/core/resource-scope'
+import { type ResourceScope, resourceScopeKey } from '@/lib/core/resource-scope'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { matchSnippet } from '@/lib/knowledge/search/snippet'
import type { SearchResource } from '@/lib/mothership/generated/resources'
@@ -12,6 +12,15 @@ import {
type SourceTagData,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+/** A new query, reader or access scope starts a fresh search and rolling-date anchor. */
+export function liveSearchResultsKey(
+ scope: ResourceScope,
+ userId: string | undefined,
+ query: string
+): string {
+ return JSON.stringify([resourceScopeKey(scope), userId, query])
+}
+
/**
* One card per document, keeping the best-ranked chunk of each: the list is
* already in rank order, so the first chunk seen for a document is its best.
@@ -81,5 +90,4 @@ export interface SearchResultsProps {
query: string
/** Binds the Assistant turn to the selected canonical document. */
onSummarize?: (prompt: string, filters: WorkspaceSearchFilters) => void
- onSearchChange?: (search: SearchResource) => void
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index f1fdb49e952..5dd05741557 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -39,8 +39,10 @@ import {
inlineChatImageUrl,
isInlineFileReference,
} from '@/lib/mothership/chat/inline-image-reference'
-import { inter } from '@/app/_styles/fonts/inter/inter'
-import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
+import {
+ useChatChrome,
+ useChatSurface,
+} from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize'
import {
ExternalLink,
@@ -576,6 +578,7 @@ function ChatContentInner({
linkSources,
}: ChatContentProps) {
const { chatId } = useChatSurface()
+ const { fontClassName } = useChatChrome()
const imageRehypePlugins = useMemo<
NonNullable['rehypePlugins']>
>(
@@ -779,7 +782,7 @@ function ChatContentInner({
-
+
{groups.map((group, i) => {
if (group.kind === 'inline') {
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx
index 3de491dad48..099b262aea2 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx
@@ -14,7 +14,7 @@ import {
useScrollEdges,
} from '@sim/emcn'
import { BookOpen } from '@sim/emcn/icons'
-import { inter } from '@/app/_styles/fonts/inter/inter'
+import { useChatChrome } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
@@ -25,6 +25,7 @@ const BUTTON_CLASSES =
interface MessageSourcesProps {
sources: readonly SourceTagData[]
onViewAll?: () => void
+ popoverClassName?: string
}
/**
@@ -33,7 +34,8 @@ interface MessageSourcesProps {
* there for whoever wants it without a second block under the answer. Opens a
* popover of one dense row per document.
*/
-export function MessageSources({ sources, onViewAll }: MessageSourcesProps) {
+export function MessageSources({ sources, onViewAll, popoverClassName }: MessageSourcesProps) {
+ const { fontClassName } = useChatChrome()
const [open, setOpen] = useState(false)
const [scrollElement, setScrollElement] = useState(null)
const edges = useScrollEdges(scrollElement, { enabled: open })
@@ -57,7 +59,7 @@ export function MessageSources({ sources, onViewAll }: MessageSourcesProps) {
align='start'
side='top'
sideOffset={4}
- className={cn('w-[420px] p-0', inter.className)}
+ className={cn('w-[420px] p-0', fontClassName, popoverClassName)}
>