diff --git a/.claude/rules/canon.md b/.claude/rules/canon.md new file mode 100644 index 00000000000..d7b9de3de64 --- /dev/null +++ b/.claude/rules/canon.md @@ -0,0 +1,50 @@ +# Canon — The 7 Canonical Concerns + +The non-negotiable conventions every change is measured against. This file is the index — the full rules live in the referenced files. When a canon rule and any other guidance conflict, canon wins. Known gaps are flagged so we close them deliberately instead of re-discovering them. + +## 1. Next.js best practices + +Server Components by default; `'use client'` only on the smallest leaf. `page.tsx` owns metadata. `next/image` with `priority` on the LCP element; `next/dynamic` for below-fold. Client refs are never called server-side (enforced by `scripts/check-client-boundary-imports.ts`). + +- Full rules: `apps/sim/app/(landing)/CLAUDE.md` (landing), `.claude/rules/sim-architecture.md` (server boundary) +- **Gap**: no general app-router conventions rule — when to add `error.tsx` / `loading.tsx` / `not-found.tsx`, `generateMetadata` vs static `metadata`, ISR/`revalidate`, Suspense/streaming. Landing doc covers landing only. + +## 2. SEO / GEO (landing only) + +One `

` (Hero), strict heading hierarchy, `
`, server-rendered navbar, JSON-LD, answer-first H2s, atomic extractable blocks, entity consistency ("Sim", never "the platform"), sr-only summaries, concrete numbers. Copy follows `.claude/rules/constitution.md`. + +- Full rules: `.claude/rules/landing-seo-geo.md`, `apps/sim/app/(landing)/CLAUDE.md` +- **Gap**: docs reference a single `structured-data.tsx` but code is split into `site-structured-data/`, `home-structured-data/`, `json-ld/`. `sitemap.ts` / `robots.ts` / `manifest.ts` conventions (app root) are undocumented. + +## 3. Feature file structure + +Every feature dir: `feature.tsx` + `page.tsx` (+ `error.tsx`, `loading.tsx`, `search-params.ts` where applicable) + `utils/` (only for 2+ consumers — single-consumer helpers stay in `feature.tsx`) + `hooks/` + `components/`. Every component lives in its own kebab-case folder holding `.tsx` + `index.ts` barrel; children nest under that folder's own `components/`, recursively. Never a bare `.tsx` flat inside a `components/` directory. Reference implementation: `apps/sim/app/workspace/[workspaceId]/scheduled-tasks/`. + +- Full rules: `apps/sim/app/(landing)/CLAUDE.md` "Structure", `.claude/rules/sim-architecture.md` +- **Gap**: the recursion + barrel rule is only fully written in the landing CLAUDE.md; `sim-architecture.md` shows a flatter sketch and omits `search-params.ts` / `error.tsx` / `loading.tsx` co-location. + +## 4. EMCN components only (platform) + +No custom buttons/inputs/menus — always the `@sim/emcn` chip-family equivalent. Components own their chrome; consumers pass props, never chrome via `className` (layout/sizing only). + +- Full rules: `.claude/rules/emcn-components.md` (authoring), `.claude/rules/sim-styling.md` (consumer) +- **Gap**: stale paths — EMCN moved to `packages/emcn/` but `emcn-components.md` frontmatter still scopes `apps/sim/components/emcn/**`, and the landing CLAUDE.md still says import from `@/components/emcn`. + +## 5. No ad-hoc animations or colors + +Colors come from tokens in `apps/sim/app/_styles/globals.css` / `tailwind.config.ts` — never raw hex in components. Animations: prefer CSS, respect `prefers-reduced-motion`, no new keyframes outside the Tailwind config / scoped `.module.css`. Never touch global styles. + +- Full rules: `.claude/rules/sim-styling.md` (tokens), `apps/sim/app/(landing)/CLAUDE.md` (motion) +- **Gap**: the positive rule ("declare custom keyframes/tokens HERE and nowhere else") is unwritten — only the prohibition exists. + +## 6. State placement (useState / Zustand / React Query / URL) + +One four-way decision: React Query = all server state · nuqs URL params = shareable view-state · Zustand = high-frequency, ephemeral, or socket-synced state · useState = purely local UI. Never `useState` + `fetch`; never store-synced-with-effects for view-state. + +- Full rules: `.claude/rules/sim-url-state.md` (the canonical 4-way table), `.claude/rules/sim-queries.md`, `.claude/rules/sim-stores.md` + +## 7. Meta — authoring skills, rules, and CLAUDE.md files + +How we write the docs themselves: CLAUDE.md stays a lean index; detailed conventions go in `.claude/rules/*.md` with `paths:` frontmatter globs so they load only when matching files are touched; repeatable multi-step procedures become skills (`.claude/skills/` or `.claude/commands/`); one-off preferences go in memory, not the repo. + +- **Gap**: no authoring guide exists — rule/skill conventions are learned by imitating existing files. Needs a short `.claude/rules/meta-authoring.md`. diff --git a/.claude/rules/sim-resource-views.md b/.claude/rules/sim-resource-views.md new file mode 100644 index 00000000000..709d75597aa --- /dev/null +++ b/.claude/rules/sim-resource-views.md @@ -0,0 +1,134 @@ +--- +paths: + - "apps/sim/resources/**/*.ts" + - "apps/sim/components/resources/**/*.ts" + - "apps/sim/components/resources/**/*.tsx" + - "apps/sim/app/workspace/[workspaceId]/**/*.tsx" + - "apps/sim/app/f/**/*.tsx" + - "apps/sim/app/i/**/*.tsx" + - "apps/sim/app/(interfaces)/**/*.tsx" + - "apps/sim/hooks/queries/workspace-files.ts" +--- + +# Resource Views + +A **resource** is a thing a workspace holds that can also be shared: a file, a table, an interface, a knowledge base, a log, a scheduled task. A resource with a canonical view has **exactly one**, and every consumer mounts that one — the workspace route page, the mothership panel, an interface module, and the public share page. + +**One view per resource. Consumers construct the axes and mount it. They never wrap it.** + +Enforced by `bun run check:resources` (strict CI gate: `bun run check:resources:strict`), which is `scripts/check-resource-views.ts`. + +## The three axes + +`apps/sim/resources/**` is pure TypeScript — no React, no `'use client'` — because `app/i/[token]/page.tsx` builds a share source during SSR. + +| Axis | Type | Replaces | +| --- | --- | --- | +| `source` | `WorkspaceSource \| ShareSource`, discriminated on `via` | `workspaceId`, `token`, `contentSource`, `isPublic`, `isShared` | +| `grants` | `{ write: boolean; run: boolean }` | `canEdit`, `canRun`, `canAdmin`, `canDelete`, `disableEdit/Insert/Delete` | +| `host` | `'page' \| 'panel' \| 'public'` | `embedded`, `isEmbedded`, `compact`, `minimal` | + +There is no fourth axis. Agent streaming is **one optional prop on `FileView`** (`streaming?: FileViewStreaming`), because only files stream. + +`ShareSource` declares `workspaceId?: never` and `resourceId?: never`, and `WorkspaceSource` declares `token?: never` and `seed?: never`. A share source **cannot** carry a workspace id — that is a compile error, not a convention. A kind whose seed is typed `never` (`knowledge`, `log`, `schedule`) structurally cannot construct a share source at all: "no public surface" is a compile-time fact. + +``` +apps/sim/resources/ # kinds.ts · source.ts · grants.ts · host.ts — pure TS +apps/sim/components/resources// # 'use client' — THE view, one per resource +``` + +A resource kind with no canonical view yet (`table`, `knowledge`, `log`, `schedule`) is simply **absent** from `CANONICAL_UNITS` in the check. That is the correct state for an unmigrated kind. Do not add a flag, a shim, or a placeholder entry for it. + +## Consume: construct the axes, then mount + +That is the whole job. Same component, same props; only the constructed values differ. + +```typescript +// app/f/[token]/public-file-view.tsx — anonymous share +const source = useMemo( + () => shareSource({ kind: 'file', token, grantId: token, seed: { name, type, size, version } }), + [token, name, type, size, version] +) +return +``` + +```typescript +// .../mothership-view/.../resource-content.tsx — panel, same view +const source = useMemo( + () => workspaceSource({ kind: 'file', workspaceId, resourceId: file.id }), + [workspaceId, file.id] +) +const grants = useMemo(() => grantsFromPermissions(permissions), [permissions]) +return +``` + +- Import from the **unit barrel** (`@/components/resources/file-view`), never a file inside it. +- Copy that differs between workspace and share belongs on the **source** (`source.unavailableCopy`), not in the view. A share must never say "workspace" — that is what stops the view becoming an existence oracle. +- Links belong on the source too (`source.hrefFor(link)`), which returns `null` in share scope so nobody hand-builds `/workspace/${token}/…`. +- `host` decides chrome and URL ownership. `hostOwnsUrl(host)` is the single place the "embedded views do not write nuqs keys" rule lives. + +## Never do this + +**Never wrap a view.** A component whose body is a canonical view with its own props forwarded in is a wrapper. `check:resources` fails on the first one (`wrapperMounts` is at `0`). + +```typescript +// ✗ Bad — adds a name, a file, and an import hop; adds no behavior. +export function EmbeddedFilePanel({ source, grants, host }: EmbeddedFilePanelProps) { + return +} + +// ✓ Good — the consumer constructs the axes and mounts the view itself. +const source = workspaceSource({ kind: 'file', workspaceId, resourceId }) +return +``` + +**Never add a fourth spelling.** If the view cannot express what you need, change `source` / `grants` / `host` — one place, every consumer — or collapse the need into an existing optional object (`streaming`, `editing`). Do not add a loose prop. + +```typescript +// ✗ Bad — three axes, spelled four wrong ways. + + +// ✓ Good + +``` + +**Never reimplement.** If a view has no seam for what you need, **add the seam**. A hand-rolled mini-table loses booleans, JSON, dates, links, resource chips, pinned columns and windowing — every one of which the real view already handles. + +**Never reach past the barrel.** + +```typescript +// ✗ Bad — binds you to the unit's private layout +import { resolveFileCategory } from '@/components/resources/file-view/file-category' + +// ✓ Good +import { resolveFileCategory } from '@/components/resources/file-view' +``` + +The one sanctioned exception is a `lazy()` code-split point, where routing through the barrel silently re-attaches the split chunk (`apps/sim` has no `sideEffects: false`). Those go in `INTERNAL_IMPORT_ALLOWLIST` in the check, keyed by importer **and** specifier. + +**Never import the workspace route tree from an anonymous surface.** `app/f/**`, `app/i/**`, `app/(interfaces)/**`, `app/(shared)/**` and public API routes may not import `@/app/workspace/[workspaceId]/**`. Shared units live in `apps/sim/components/resources/**`. Nesting under a `[workspaceId]` segment is exactly why `workspaceId: string` once read as natural on a component anonymous visitors mounted with a **share token**. + +**Never read route or permission context inside a unit.** No `useRouter`, `useParams`, `useSearchParams`, `usePathname`, `useQueryState(s)`, or `useUserPermissionsContext` under `apps/sim/components/resources/**`. Addressing is `source`, navigation targets are `source.hrefFor(link)`, capability is `grants`, URL ownership is `host`. A component that falls back to `useParams()` can only ever exist once per page. + +**Never put `'use client'` in `apps/sim/resources/**`.** Next rewrites every export of a `'use client'` module into a client reference in the server bundle, so the Server Component that builds a share source would throw at runtime. + +## Escape hatch + +Four annotations, reason mandatory, on the line directly above the offending mount / import / attribute (up to three preceding comment lines of extra context are tolerated): + +```typescript +// boundary-resource-wrapper: +// boundary-resource-internal: +// boundary-resource-tree: +// boundary-resource-prop: +``` + +An annotation with an empty reason is still a finding **and** trips `annotationsMissingReason`. Whole-file exceptions go through `INTERNAL_IMPORT_ALLOWLIST` / `CROSS_TREE_ALLOWLIST` in `scripts/check-resource-views.ts`, not per-line annotations. + +## Checklist before you add a component near a resource + +1. Does a canonical view already exist for this kind? Mount it. +2. Does it exist but lack a seam? Add the seam in the unit and thread it — do not fork the UI. +3. Is your new component only forwarding props into a view? Delete it; mount the view at the call site. +4. Are you about to write `embedded`, `canEdit`, `canRun`, `isPublic`, `token` or `workspaceId` on a view? Map it to `source` / `grants` / `host`. +5. Run `bun run check:resources`. The success metric is **consumers per view going up and component count going down**. diff --git a/.cursor/rules/sim-resource-views.mdc b/.cursor/rules/sim-resource-views.mdc new file mode 100644 index 00000000000..5988ec66cff --- /dev/null +++ b/.cursor/rules/sim-resource-views.mdc @@ -0,0 +1,99 @@ +--- +description: Resource views — one view per resource, many consumers, no wrappers +globs: ["apps/sim/resources/**/*.ts", "apps/sim/components/resources/**/*.ts", "apps/sim/components/resources/**/*.tsx", "apps/sim/app/workspace/[workspaceId]/**/*.tsx", "apps/sim/app/f/**/*.tsx", "apps/sim/app/i/**/*.tsx", "apps/sim/app/(interfaces)/**/*.tsx"] +--- +# Resource Views + +A **resource** is a thing a workspace holds that can also be shared: a file, a table, an interface, a knowledge base, a log, a scheduled task. A resource with a canonical view has **exactly one**, and every consumer mounts that one — the workspace page, the mothership panel, an interface module, the public share page. + +**One view per resource. Consumers construct the axes and mount it. They never wrap it.** + +Enforced by `bun run check:resources`; strict gate `bun run check:resources:strict`. + +## Three axes, nothing else + +| Axis | Type | Replaces | +| --- | --- | --- | +| `source` | `WorkspaceSource \| ShareSource`, discriminated on `via` | `workspaceId`, `token`, `contentSource`, `isPublic` | +| `grants` | `{ write: boolean; run: boolean }` | `canEdit`, `canRun`, `canAdmin`, `disableEdit/Insert/Delete` | +| `host` | `'page' \| 'panel' \| 'public'` | `embedded`, `isEmbedded`, `compact`, `minimal` | + +There is no fourth axis. Agent streaming is one optional prop on `FileView` (`streaming?`), because only files stream. + +`ShareSource` declares `workspaceId?: never`; `WorkspaceSource` declares `token?: never`. A share source cannot carry a workspace id — compile error, not convention. + +``` +apps/sim/resources/ # kinds/source/grants/host — pure TS, NO 'use client' +apps/sim/components/resources// # 'use client' — THE view, one per resource +``` + +A kind with no canonical view yet (`table`, `knowledge`, `log`, `schedule`) is simply absent from the check's `CANONICAL_UNITS`. Do not add a flag, shim, or placeholder for it. + +## Consume: construct, then mount + +```typescript +// Anonymous share +const source = shareSource({ kind: 'file', token, grantId: token, seed }) +return + +// Panel — same view, same props +const source = workspaceSource({ kind: 'file', workspaceId, resourceId: file.id }) +return +``` + +Import from the unit barrel (`@/components/resources/file-view`), never a file inside it. Scope-dependent copy lives on `source.unavailableCopy`; links on `source.hrefFor(link)` (which returns `null` in share scope). `hostOwnsUrl(host)` is the one place the "embedded views do not write nuqs keys" rule lives. + +## Never + +```typescript +// ✗ Wrapper — adds a name, a file, an import hop; adds no behavior. Fails check:resources. +export function EmbeddedFilePanel({ source, grants, host }: EmbeddedFilePanelProps) { + return +} + +// ✓ The consumer constructs the axes and mounts the view itself +const source = workspaceSource({ kind: 'file', workspaceId, resourceId }) +return +``` + +```typescript +// ✗ A fourth spelling for capability / chrome / address + + +// ✓ + +``` + +```typescript +// ✗ Reaching past the barrel +import { resolveFileCategory } from '@/components/resources/file-view/file-category' + +// ✓ +import { resolveFileCategory } from '@/components/resources/file-view' +``` + +- **Never reimplement** a resource's UI because the view has no seam — add the seam. A hand-rolled mini-table loses booleans, JSON, dates, links, chips, pinned columns and windowing. +- **Never import `@/app/workspace/[workspaceId]/**`** from `app/f/**`, `app/i/**`, `app/(interfaces)/**`, `app/(shared)/**`, or a public API route. Shared units live in `apps/sim/components/resources/**`. +- **Never** use `useRouter` / `useParams` / `useSearchParams` / `usePathname` / `useQueryState(s)` / `useUserPermissionsContext` inside `apps/sim/components/resources/**`. Addressing is `source`, navigation is `source.hrefFor`, capability is `grants`, URL ownership is `host`. +- **Never** put `'use client'` in `apps/sim/resources/**` — a Server Component builds a share source from it during SSR. + +## Escape hatch + +Reason mandatory, on the line directly above the offending mount / import / attribute: + +```typescript +// boundary-resource-wrapper: +// boundary-resource-internal: +// boundary-resource-tree: +// boundary-resource-prop: +``` + +An empty reason is still a finding. Whole-file exceptions go in `INTERNAL_IMPORT_ALLOWLIST` / `CROSS_TREE_ALLOWLIST` in `scripts/check-resource-views.ts`. The only sanctioned deep import is a `lazy()` split point, where the barrel would re-attach the split chunk. + +## Before adding a component near a resource + +1. Does a canonical view exist for this kind? Mount it. +2. Exists but lacks a seam? Add the seam in the unit — do not fork. +3. Only forwarding props into a view? Delete it; mount the view at the call site. +4. About to write `embedded` / `canEdit` / `canRun` / `isPublic` / `token` / `workspaceId` on a view? Map it to `source` / `grants` / `host`. +5. Run `bun run check:resources`. Success is consumers-per-view up, component count down. diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index c072d19f149..e704907b4a4 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -123,6 +123,9 @@ jobs: - name: API contract boundary audit run: bun run check:api-validation:strict + - name: Resource view boundary audit + run: bun run check:resources + - name: Desktop bridge contract audit run: bun run check:desktop-bridge @@ -178,11 +181,6 @@ jobs: - name: Type-check realtime server run: bunx turbo run type-check --filter=@sim/realtime - # cloud-review-tools.test.ts runs the real helper on the runner, which shells - # out to rg. Blacksmith's image ships it, GitHub's doesn't. - - name: Install ripgrep - run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep) - # Named for what it does: `bun run test` is `vitest run`, with no # `--coverage`. See the Codecov note below. - name: Run tests diff --git a/CLAUDE.md b/CLAUDE.md index d2e376dbd9f..ff16c8e2af4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ You are a professional software engineer. All code must follow best practices: a ## Global Standards -- **Linting / Audit**: `bun run check:api-validation` must pass on PRs. Do not introduce route-local boundary Zod schemas, direct route Zod imports, or ad-hoc client wire types — see "API Contracts" and "API Route Pattern" below +- **Linting / Audit**: `bun run check:api-validation` and `bun run check:resources` must pass on PRs. Do not introduce route-local boundary Zod schemas, direct route Zod imports, ad-hoc client wire types, or wrappers over a canonical resource view — see "API Contracts", "API Route Pattern", and "Resource Views" below - **Logging**: Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`. Inside API routes wrapped with `withRouteHandler`, loggers automatically include the request ID — no manual `withMetadata({ requestId })` needed - **API Route Handlers**: All API route handlers (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`) must be wrapped with `withRouteHandler` from `@/lib/core/utils/with-route-handler`. This provides request ID tracking, automatic error logging for 4xx/5xx responses, and unhandled error catching. See "API Route Pattern" section below - **Comments**: Use TSDoc for documentation. No `====` separators. No non-TSDoc comments @@ -41,6 +41,7 @@ apps/ │ ├── hooks/ # Shared hooks (queries/, selectors/) │ ├── lib/ # App-wide utilities │ ├── providers/ # LLM provider integrations +│ ├── resources/ # Resource axes (source/grants/host) — pure TS, server-importable │ ├── stores/ # Zustand stores │ ├── tools/ # Tool definitions │ └── triggers/ # Trigger definitions @@ -413,6 +414,20 @@ Principles when building or migrating shared UI: - Align consumers to the canonical defaults — normal weight, `--text-body` text, `--text-icon` icons. - Verify referenced CSS vars exist — an undefined var silently falls back to `currentColor` (black-bug). +## Resource Views + +A **resource** is a thing a workspace holds that can also be shared — a file, a table, an interface, a knowledge base, a log, a scheduled task. A resource with a canonical view has exactly ONE, in `apps/sim/components/resources//`, mounted by every consumer: the workspace route page, the mothership panel, an interface module, the public share page. + +Views are mounted against exactly **three axes**, defined in `apps/sim/resources/**` (pure TypeScript — no React, no `'use client'`, because a Server Component builds a share source during SSR): + +- `source` — where the data comes from and by what address: `WorkspaceSource | ShareSource`, discriminated on `via`. Replaces `workspaceId`, `token`, `contentSource`, `isPublic`. `ShareSource` declares `workspaceId?: never`, so a share token can no longer be laundered through a workspace-shaped slot. +- `grants` — what this viewer may do: `{ write, run }`. Replaces `canEdit`, `canRun`, `canAdmin`, `disableEdit/Insert/Delete`. +- `host` — who owns the URL, the router, the document frame: `'page' | 'panel' | 'public'`. Replaces `embedded`. `hostOwnsUrl(host)` is the one place the "embedded views do not write nuqs keys" rule lives. + +There is no fourth axis; agent streaming is one optional prop on `FileView`. Consumers CONSTRUCT the axes and MOUNT the view — never wrap it in a passthrough, never reach past its barrel, never reimplement its UI because it lacks a seam (add the seam), never import `@/app/workspace/[workspaceId]/**` from an anonymous surface (`app/f/**`, `app/i/**`, `app/(interfaces)/**`), and never read `useRouter`/`useParams`/`useQueryState`/`useUserPermissionsContext` inside a unit. A kind with no canonical view yet (`table`, `knowledge`, `log`, `schedule`) is simply absent from the check's `CANONICAL_UNITS` — no flag, shim, or placeholder. + +Enforced by `bun run check:resources` (strict: `check:resources:strict`), which ratchets counters for wrappers, imports past a barrel, cross-tree imports, unsanctioned props, token-as-`workspaceId`, and context leaks. Escape hatches — reason mandatory, on the line directly above: `// boundary-resource-wrapper:`, `// boundary-resource-internal:`, `// boundary-resource-tree:`, `// boundary-resource-prop:`. Full rules in `.claude/rules/sim-resource-views.md`. + ## Testing Use Vitest. Test files: `feature.ts` → `feature.test.ts`. See `.cursor/rules/sim-testing.mdc` for full details. diff --git a/apps/sim/AGENTS.md b/apps/sim/AGENTS.md index 6c52c2df02d..10227490ebd 100644 --- a/apps/sim/AGENTS.md +++ b/apps/sim/AGENTS.md @@ -229,3 +229,11 @@ export function useEntityList(workspaceId?: string) { - **Check existing sources** before duplicating (`lib/` has many utilities) - **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use) + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + diff --git a/apps/sim/app/(auth)/components/auth-shell.tsx b/apps/sim/app/(auth)/components/auth-shell.tsx index 36085dc52ad..40dc82ad5e4 100644 --- a/apps/sim/app/(auth)/components/auth-shell.tsx +++ b/apps/sim/app/(auth)/components/auth-shell.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from 'react' -import Link from 'next/link' import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' -import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components' +import { Navbar } from '@/app/(landing)/components/navbar' interface AuthShellProps { /** Centered content column (the form, status copy, etc.). */ @@ -16,9 +15,9 @@ interface AuthShellProps { * * Mirrors the landing chrome: it pins the `light` token layer (so the platform's * light-mode `var(--*)` tokens resolve regardless of the visitor's theme), uses - * the canvas/`--text-primary` surface, and renders a logo-only header that reuses - * the landing {@link LogoMark} + {@link SimWordmark} at the same nav gutters. The - * single content column is centered and capped for a calm single-form layout. + * the canvas/`--text-primary` surface, and renders the shared {@link Navbar} in + * `logoOnly` mode — the same wordmark, geometry, and hover as the landing bar. + * The single content column is centered and capped for a calm single-form layout. * * The shell also owns the macOS traffic-light lane, unconditionally — every surface that * wears it (the `(auth)` routes, the CLI auth handoff, the invite pages) sits outside @@ -32,15 +31,7 @@ export function AuthShell({ children, footer }: AuthShellProps) { return (
-
- -
+
{children}
diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx index 8ab6df2075b..321b420f763 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx @@ -24,7 +24,6 @@ import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/(interfaces) import { useAudioStreaming, useChatStreaming } from '@/app/(interfaces)/chat/hooks' import SSOAuth from '@/ee/sso/components/sso-auth' import { useDeployedChatConfig } from '@/hooks/queries/chats' -import { useGitHubStars } from '@/hooks/queries/github-stars' import { useVoiceSettings } from '@/hooks/queries/voice-settings' const logger = createLogger('ChatClient') @@ -108,7 +107,6 @@ export default function ChatClient({ identifier }: { identifier: string }) { const { data: chatConfigResult, error: chatConfigError } = useDeployedChatConfig(identifier) const { data: voiceSettings } = useVoiceSettings() - const { data: starCount } = useGitHubStars() const sttAvailable = voiceSettings?.sttAvailable === true const authRequired = chatConfigResult?.kind === 'auth' ? chatConfigResult.authType : null @@ -444,7 +442,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
{/* Header component */} - + {/* Message Container component */} -
-
- {customImage && ( - {`${chatConfig?.title - )} -

- {chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'} -

-
-
- - {!brand.logoUrl && ( -
- - - {/* Only show Sim logo if no custom branding is set */} - - - - -
- )} - + } + nameIcon={ + customImage ? ( + {`${chatConfig?.title + ) : undefined + } + /> ) } diff --git a/apps/sim/app/(interfaces)/chat/components/input/input.tsx b/apps/sim/app/(interfaces)/chat/components/input/input.tsx index 63efde5b042..f13013a7fd7 100644 --- a/apps/sim/app/(interfaces)/chat/components/input/input.tsx +++ b/apps/sim/app/(interfaces)/chat/components/input/input.tsx @@ -13,7 +13,11 @@ const logger = createLogger('ChatInput') const MAX_TEXTAREA_HEIGHT = 200 -interface AttachedFile { +/** Today's deployed-chat budget — unchanged defaults for every existing caller. */ +const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024 +const DEFAULT_MAX_FILES = 15 + +export interface AttachedFile { id: string name: string size: number @@ -29,6 +33,30 @@ export const ChatInput: React.FC<{ onVoiceStart?: () => void voiceOnly?: boolean sttAvailable?: boolean + /** Inert composer — the interface canvas renders one in edit mode, where nothing runs. */ + disabled?: boolean + /** + * Whether the composer pins itself to the bottom of the viewport, which is + * what a full-page chat wants. `false` lets it sit inside a bounded container + * — an interface module's pane — as an ordinary block. + */ + docked?: boolean + /** Placeholder override; defaults to the deployed chat's own prompt. */ + placeholder?: string + /** + * Whether the attach control is offered. Off for surfaces whose run endpoint + * takes no inbound files, so the composer never presents an affordance that + * would silently drop what the user attached. + */ + allowAttachments?: boolean + /** + * Per-file and per-turn caps. Attachments travel inline as base64, so the + * ceiling is really the receiving route's body limit — a surface posting to + * a smaller one lowers these rather than letting the user attach something + * the request will reject. + */ + maxFileSizeBytes?: number + maxFiles?: number }> = ({ onSubmit, isStreaming = false, @@ -36,6 +64,12 @@ export const ChatInput: React.FC<{ onVoiceStart, voiceOnly = false, sttAvailable = false, + disabled = false, + docked = true, + placeholder, + allowAttachments = true, + maxFileSizeBytes = DEFAULT_MAX_FILE_BYTES, + maxFiles = DEFAULT_MAX_FILES, }) => { const fileInputRef = useRef(null) const textareaRef = useRef(null) @@ -53,11 +87,10 @@ export const ChatInput: React.FC<{ }, [inputValue]) const handleFileSelect = async (selectedFiles: FileList | null) => { - if (!selectedFiles) return + if (!selectedFiles || !attachmentsEnabled) return const newFiles: AttachedFile[] = [] - const maxSize = 10 * 1024 * 1024 - const maxFiles = 15 + const maxSize = maxFileSizeBytes for (let i = 0; i < selectedFiles.length; i++) { if (attachedFiles.length + newFiles.length >= maxFiles) break @@ -139,7 +172,12 @@ export const ChatInput: React.FC<{ textareaRef.current?.focus() }, []) - const canSubmit = (inputValue.trim().length > 0 || attachedFiles.length > 0) && !isStreaming + /** One flag for every attach path — button, drag-drop, and the send gate. */ + const attachmentsEnabled = allowAttachments && !disabled + const canSubmit = + (inputValue.trim().length > 0 || (attachmentsEnabled && attachedFiles.length > 0)) && + !isStreaming && + !disabled if (voiceOnly) { return ( @@ -168,8 +206,14 @@ export const ChatInput: React.FC<{ return ( -
-
+
+
{/* Error Messages */} {uploadErrors.length > 0 && (
@@ -192,17 +236,17 @@ export const ChatInput: React.FC<{ }} className={cn( 'relative z-10 cursor-text rounded-2xl border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2', - isDragOver && 'border-purple-500' + isDragOver && 'border-[var(--text-muted)]' )} onDragEnter={(e) => { e.preventDefault() e.stopPropagation() - if (!isStreaming) setDragCounter((prev) => prev + 1) + if (!isStreaming && attachmentsEnabled) setDragCounter((prev) => prev + 1) }} onDragOver={(e) => { e.preventDefault() e.stopPropagation() - if (!isStreaming) e.dataTransfer.dropEffect = 'copy' + if (!isStreaming && attachmentsEnabled) e.dataTransfer.dropEffect = 'copy' }} onDragLeave={(e) => { e.preventDefault() @@ -213,7 +257,7 @@ export const ChatInput: React.FC<{ e.preventDefault() e.stopPropagation() setDragCounter(0) - if (!isStreaming) handleFileSelect(e.dataTransfer.files) + if (!isStreaming && attachmentsEnabled) handleFileSelect(e.dataTransfer.files) }} > {/* File thumbnails */} @@ -263,8 +307,11 @@ export const ChatInput: React.FC<{ value={inputValue} onChange={(e) => setInputValue(e.target.value)} onKeyDown={handleKeyDown} - placeholder={isDragOver ? 'Drop files here...' : 'Enter a message...'} + placeholder={ + isDragOver ? 'Drop files here...' : (placeholder ?? 'Enter a message...') + } rows={1} + disabled={disabled} className='m-0 h-auto min-h-[24px] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent p-1 text-[15px] text-[var(--text-primary)] leading-[24px] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden' /> @@ -272,21 +319,25 @@ export const ChatInput: React.FC<{
{/* Left: attach */}
- - - - - -

Attach files

-
-
+ {allowAttachments && ( + + + + + +

Attach files

+
+
+ )} , path: string): unknown { + if (!path.includes('.')) return undefined + return path.split('.').reduce((current, segment) => { + if (current && typeof current === 'object' && segment in current) { + return (current as Record)[segment] + } + return undefined + }, output) +} + function extractFilesFromData( data: any, files: ChatFile[] = [], @@ -42,15 +64,7 @@ function extractFilesFromData( if (isUserFileWithMetadata(data)) { if (!seenIds.has(data.id)) { seenIds.add(data.id) - files.push({ - id: data.id, - name: data.name, - url: data.url, - key: data.key, - size: data.size, - type: data.type, - context: data.context, - }) + files.push(toChatFileMetadata(data)) } return files } @@ -124,8 +138,7 @@ export function useChatStreaming() { if (lastMessage && lastMessage.type === 'assistant') { const content = latestContent || lastMessage.content - const updatedContent = - content + (content ? '\n\n_Response stopped by user._' : '_Response stopped by user._') + const updatedContent = content ? `${content}\n\n${CHAT_STOPPED_NOTE}` : CHAT_STOPPED_NOTE return [ ...prev.slice(0, -1), @@ -410,74 +423,15 @@ export function useChatStreaming() { const formattedOutputs: string[] = [] let extractedFiles: ChatFile[] = [] - const formatValue = (value: any): string | null => { - if (value === null || value === undefined) { - return null - } - - if (isUserFileWithMetadata(value)) { - return null - } - - if (Array.isArray(value) && value.length === 0) { - return null - } - - if (typeof value === 'string') { - return value - } - - if (typeof value === 'object') { - try { - return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\`` - } catch { - return String(value) - } - } - - return String(value) - } - - const getOutputValue = (blockOutputs: Record, path?: string) => { - if (!path || path === 'content') { - if (blockOutputs.content !== undefined) return blockOutputs.content - if (blockOutputs.result !== undefined) return blockOutputs.result - return blockOutputs - } - - if (blockOutputs[path] !== undefined) { - return blockOutputs[path] - } - - if (path.includes('.')) { - return path.split('.').reduce((current, segment) => { - if (current && typeof current === 'object' && segment in current) { - return current[segment] - } - return undefined - }, blockOutputs) - } - - return undefined - } - if (outputConfigs?.length && finalData.output) { for (const config of outputConfigs) { const blockOutputs = finalData.output[config.blockId] if (!blockOutputs) continue - const value = getOutputValue(blockOutputs, config.path) + const value = resolveChatOutputValue(blockOutputs, config.path, resolveDotPath) if (isUserFileWithMetadata(value)) { - extractedFiles.push({ - id: value.id, - name: value.name, - url: value.url, - key: value.key, - size: value.size, - type: value.type, - context: value.context, - }) + extractedFiles.push(toChatFileMetadata(value)) continue } @@ -487,7 +441,7 @@ export function useChatStreaming() { continue } - const formatted = formatValue(value) + const formatted = formatChatOutputValue(value) if (formatted) { formattedOutputs.push(formatted) } @@ -515,7 +469,7 @@ export function useChatStreaming() { } } else if (finalData.success && finalData.output) { const fallbackOutput = Object.values(finalData.output) - .map((block) => formatValue(block)?.trim()) + .map((block) => formatChatOutputValue(block)?.trim()) .filter(Boolean)[0] if (fallbackOutput) { finalContent = fallbackOutput diff --git a/apps/sim/app/(interfaces)/chat/utils/attachments.ts b/apps/sim/app/(interfaces)/chat/utils/attachments.ts new file mode 100644 index 00000000000..568722ed346 --- /dev/null +++ b/apps/sim/app/(interfaces)/chat/utils/attachments.ts @@ -0,0 +1,47 @@ +import type { AttachedFile } from '@/app/(interfaces)/chat/components/input/input' + +/** + * One attachment as the chat wire format carries it: metadata plus the file + * inlined as a base64 data URL. Matches `deployedChatFileSchema` + * (`@/lib/api/contracts/chats`), which is what the server decodes and uploads. + */ +export interface ChatFilePayload { + name: string + size: number + type: string + data: string +} + +/** Reads a `File` into a `data:;base64,…` URL. */ +function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + reader.readAsDataURL(file) + }) +} + +/** + * Turns the composer's attachments into the wire payload both chat surfaces + * send — the deployed chat and an interface's chat module. + * + * The `dataUrl` fallback is load-bearing rather than an optimisation: the + * composer only pre-reads *images*, so every other attachment arrives with + * `dataUrl: undefined` and must be read here. Skipping it produces an empty + * `data`, which the server drops with only a warning — the file would vanish + * silently instead of failing. + */ +export async function toChatFilePayloads( + files: readonly AttachedFile[] | undefined +): Promise { + if (!files || files.length === 0) return [] + return Promise.all( + files.map(async (file) => ({ + name: file.name, + size: file.size, + type: file.type, + data: file.dataUrl || (await fileToBase64(file.file)), + })) + ) +} diff --git a/apps/sim/app/(landing)/components/index.ts b/apps/sim/app/(landing)/components/index.ts index 4181286a3a5..8e15831460a 100644 --- a/apps/sim/app/(landing)/components/index.ts +++ b/apps/sim/app/(landing)/components/index.ts @@ -21,6 +21,7 @@ export { Navbar } from './navbar' export { PlatformHeroVisual } from './platform-hero-visual' export { ProductDemo } from './product-demo' export { ShareButton } from './share-button' +export { ShareLinkButton } from './share-link-button' export { SiteStructuredData } from './site-structured-data' export type { SolutionsPageConfig } from './solutions-page' export { SolutionsPage } from './solutions-page' diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx index 12d9bb4693f..50cefc3b15e 100644 --- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx +++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx @@ -254,7 +254,7 @@ export const LandingPreviewHome = memo(function LandingPreviewHome({ animate={{ width: '55%', opacity: 1 }} transition={{ duration: 0.35, ease: EASE_OUT }} > - + )} @@ -382,9 +382,14 @@ function ChatMarkdown({ } /** - * Mini Customer Leads table panel matching the resource panel pattern. + * The "Customer Leads" panel in the landing hero's static product mock. + * + * Named for what it is rather than `MiniTablePanel`: it is hardcoded marketing + * markup with no queries and no relationship to the table resource, and a + * `Mini` + resource-noun name reads to both a reader and the resource-view + * audit as a per-consumer fork of the real table view. */ -function MiniTablePanel() { +function LandingLeadsPanel() { return (
diff --git a/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx b/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx index 64c25cbc09d..da5ebfd47f7 100644 --- a/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx +++ b/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx @@ -1,16 +1,15 @@ import type { ReactNode } from 'react' import { cn } from '@sim/emcn' -import Link from 'next/link' import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' -import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components' +import { Navbar } from '@/app/(landing)/components/navbar' /** - * The canonical light, logo-only page frame - a Sim wordmark linking home, no - * marketing menus, on the platform's light tokens (the `light` class pins - * light mode regardless of visitor theme). It is the shared base for every - * surface that wants minimal chrome: the global 404, and the `(interfaces)` - * group (which adds a support footer). The `(auth)` group uses its own - * `AuthShell` with the same look. + * The canonical light, logo-only page frame - the shared {@link Navbar} in + * `logoOnly` mode (Sim wordmark linking home, no marketing menus) on the + * platform's light tokens (the `light` class pins light mode regardless of + * visitor theme). It is the shared base for every surface that wants minimal + * chrome: the global 404, and the `(interfaces)` group (which adds a support + * footer). The `(auth)` group uses its own `AuthShell` with the same navbar. * * Children decide their own layout: pass `center` for a single centered column * (404 message, simple gates); omit it for full-width content (the live chat @@ -29,15 +28,7 @@ export function LogoShell({ children, center = false, footer }: LogoShellProps) return (
-
- -
+
diff --git a/apps/sim/app/(landing)/components/navbar/navbar.tsx b/apps/sim/app/(landing)/components/navbar/navbar.tsx index 2cf7458cac6..9a9110c8d8b 100644 --- a/apps/sim/app/(landing)/components/navbar/navbar.tsx +++ b/apps/sim/app/(landing)/components/navbar/navbar.tsx @@ -1,4 +1,5 @@ -import { ChipLink } from '@sim/emcn' +import type { ReactNode } from 'react' +import { ChipLink, chipContentLabelClass, chipGeometryClass, cn } from '@sim/emcn' import Link from 'next/link' import { GitHubChip, @@ -12,7 +13,11 @@ import { import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants' /** - * Landing navbar. + * The single navbar for every public surface — the landing marketing bar and, + * via {@link logoOnly}, the minimal header every non-marketing surface wears + * (auth, 404, shared file / interface). One component so the brand mark, geometry, + * and hover behaviour can never drift between surfaces; the only differences are + * what is presented (mega-menus vs a static resource name) and whether CTAs show. * * Sticky `