From bcedadf1b16c5e36d3262a0c48882408b32b2edc Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:16:40 -0700 Subject: [PATCH] feat(scheduled-tasks): calendar views + persisted, runnable tasks (#4979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(resource): simplify table shell, toasts, and loading breadcrumbs - Resource.Table: remove internal sorting (defaultSort/sortValues) and the emptyMessage state — rows render in the order given, chrome always paints - Resource: root is now the positioning context for overlays; consumers (files, tables, knowledge, document) wrap detail views in instead of hand-rolled divs - ResourceHeader: root titles no longer truncate during initial layout; LocationFocusVeil gates the portal on mount to fix a hydration mismatch - Toasts: drop the StackDismiss ring and stack countdown — each toast runs its own timer; remove the Mod+E clear-notifications command; align toast typography and icons with chip chrome - Breadcrumbs: use the canonical '…' placeholder while names load - incident.io: fix display name and catalog slug (with redirect) - Add dev:capped / dev:full:capped scripts with a 4GB heap cap * feat(scheduled-tasks): calendar views; rename Mothership to Sim/Chat Add month/time calendar views for scheduled tasks with toolbar, event chips, and a create-task modal, backed by calendar-grid and schedule-events utils (with tests) and a use-calendar hook. Replace the old schedule-modal/context-menu flow. Rename the "Mothership" agent to "Sim" and the chat surface to "Chat" across landing copy, constitution, block metadata, API error messages, and copilot/data-drain internals. Drop unused workspace route layouts. * fix(emcn): force dropdown menus modal inside dialogs so they scroll A non-modal DropdownMenu portals outside an open dialog's react-remove-scroll subtree, so its content cannot be wheel-scrolled (e.g. the time picker in the scheduled-task create modal). ModalContent now marks its subtree via an InsideModal context, and the emcn DropdownMenu root upgrades itself to modal inside dialogs so it mounts its own scroll lock and focus scope; page-level menus keep their consumer-chosen modality. Also stretch the create-task modal's date/time chip controls to full width and drop the dead EDGE_GUTTER constant left behind by the equal-tracks calendar layout. * fix(scheduled-tasks): address review — midnight rollover, stub feedback, smooth Today scroll - useCalendar: today was frozen at mount, so after midnight the isToday column highlight and the current-time indicator stayed on the previous day. today is now state refreshed by a sleep-resilient minute poll that only re-renders when the calendar day actually changes - CreateTaskModal: the stub submit closed silently, reading as false success; it now shows an info toast that the task was not created - ScheduleCalendar: Today presses scroll smoothly as an orientation cue; mount and scope switches keep instant positioning * feat(emcn): view-only field primitives + scheduled-task modals - ChipCopyInput (canonical view-only copy field), ChipTimePicker, ChipModalField type='copy', ChipTextarea viewOnly; new border chip variant and shared chipPrimaryFillTokens - migrate ~40 consumers off disabled inputs and the deleted CopyableValueField; ChipConfirmModal description->text and secondaryActions[] API sweep - scheduled-tasks: rename create-task-modal to task-modal, add task-details-modal + task-context-menu, useScheduledTasks hook - home: extract prompt-editor (usePromptEditor) out of user-input * feat(scheduled-tasks): persist + run tasks via the job-schedule backend Wire the calendar UI to the existing sourceType='job' workflow_schedule backend instead of local component state, so tasks persist and actually run as Sim agent invocations. - schema: add contexts (@-mentions resolved into the run), excludedDates (per-occurrence deletes), and endsAt (recurrence end) to workflow_schedule (migration 0235) - contracts/schedules: expose one-time `time`, contexts, endsAt on create; add the exclude_occurrence action; nullable cron in the create response - orchestration: persist the new fields, honor exclusions + end boundary via a shared computeNextRunAt, add performExcludeOccurrence - execution: forward contexts to /api/mothership/execute and recompute the next run through computeNextRunAt - mothership/execute: accept + resolve contexts like the interactive chat path - frontend: replace the local hook with React Query (create/update/delete + exclude-occurrence), expand recurrences into calendar occurrences, add the recurrence control (frequency + end) and the recurring this/all delete dialog * chore(scheduled-tasks): satisfy biome line-width on user-input imports * fix(scheduled-tasks): log agent-context resolution failures in execute route * fix(scheduled-tasks): keep context double-cast adjacent to its boundary annotation * fix(scheduled-tasks): preserve @-mention contexts on edit; sync editor valueRef on input * fix(scheduled-tasks): footer-wrap modal controls; audit fixes; cleanup - chip-modal: footer secondary cluster now wraps (min-w-0 flex-wrap) with a non-shrinking action cluster, so scheduling controls can never clip Cancel/ primary; recurrence labels compacted so the common case stays one row - schedule-execution: failure path now completes a recurring job when maxRuns/ endsAt/exclusions are exhausted (and a one-time/maxRuns job), mirroring the success path instead of leaving it active with a stale nextRunAt - prompt-editor: commitValue keeps valueRef in lockstep with state on the mention-hook setter paths, completing the stale-ref fix - task-modal: preserve @-mention contexts on edit (seed editor.setContexts); single emptiness source of truth - recurrence-control: preserve prior count when toggling end type; drop a needless useMemo - contracts: reuse scheduleContextSchema for the execute contexts shape * feat(scheduled-tasks): valid future default launch time; test scheduleToTasks mapping * chore(scheduled-tasks): convert added inline comments to TSDoc * simplify(scheduled-tasks): reuse date-fns for launch/end math; drop dead 'running' status + single-use helper --------- Co-authored-by: waleed --- .claude/rules/constitution.md | 8 +- .claude/rules/emcn-components.md | 6 +- README.md | 16 +- .../docs/en/integrations/incidentio.mdx | 2 +- .../components/features/features.tsx | 6 +- .../(landing)/components/footer/footer.tsx | 2 +- .../app/(landing)/components/hero/hero.tsx | 2 +- .../landing-preview-home.tsx | 2 +- .../landing-preview-panel.tsx | 6 +- .../(landing)/components/structured-data.tsx | 8 +- apps/sim/app/api/files/presigned/route.ts | 6 +- apps/sim/app/api/files/upload/route.ts | 2 +- apps/sim/app/api/mothership/execute/route.ts | 44 +- apps/sim/app/api/schedules/[id]/route.ts | 48 +- apps/sim/app/api/schedules/route.ts | 18 +- apps/sim/app/llms.txt/route.ts | 2 +- apps/sim/app/playground/page.tsx | 2 +- .../components/copyable-value-field.tsx | 48 - .../components/unsaved-changes-modal.tsx | 2 +- .../components/credential-detail/index.ts | 1 - .../[workspaceId]/components/index.ts | 1 + .../resource-chrome-fallback.tsx | 2 +- .../resource-header/resource-header.tsx | 45 +- .../components/resource/resource.tsx | 118 +- .../delete-confirm-modal.tsx | 18 +- .../workspace/[workspaceId]/files/files.tsx | 38 +- .../workspace/[workspaceId]/files/layout.tsx | 3 - .../thinking-block/thinking-block.tsx | 2 +- .../message-content/message-content.tsx | 3 +- .../user-input/components/constants.ts | 17 +- .../components/user-input/components/index.ts | 7 + .../plus-menu-dropdown/plus-menu-dropdown.tsx | 300 +- .../components/prompt-editor/index.ts | 7 + .../prompt-editor/prompt-editor.tsx | 204 + .../prompt-editor/use-prompt-editor.ts | 990 + .../home/components/user-input/user-input.tsx | 1060 +- .../connect-service-account-modal.tsx | 20 +- .../connected-credential-detail.tsx | 20 +- .../[workspaceId]/integrations/layout.tsx | 3 - .../delete-chunk-modal/delete-chunk-modal.tsx | 2 +- .../knowledge/[id]/[documentId]/document.tsx | 115 +- .../knowledge/[id]/[documentId]/loading.tsx | 2 +- .../[workspaceId]/knowledge/[id]/base.tsx | 118 +- .../base-tags-modal/base-tags-modal.tsx | 22 +- .../connectors-section/connectors-section.tsx | 2 +- .../[workspaceId]/knowledge/[id]/loading.tsx | 2 +- .../delete-knowledge-base-modal.tsx | 40 +- .../[workspaceId]/knowledge/knowledge.tsx | 4 +- .../[workspaceId]/knowledge/layout.tsx | 3 - .../workflows-list/workflows-list.tsx | 2 +- .../workspace/[workspaceId]/logs/layout.tsx | 3 - .../workspace/[workspaceId]/logs/loading.tsx | 3 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 2 - .../workspace-permissions-provider.tsx | 17 - .../components/create-schedule-modal/index.ts | 1 - .../create-schedule-modal/schedule-modal.tsx | 528 - .../calendar-event-chip.tsx | 59 + .../components/calendar-event-chip/index.ts | 1 + .../calendar-toolbar/calendar-toolbar.tsx | 84 + .../components/calendar-toolbar/index.ts | 1 + .../schedule-calendar/components/index.ts | 4 + .../components/month-grid/index.ts | 1 + .../components/month-grid/month-grid.tsx | 170 + .../components/time-grid/index.ts | 1 + .../components/time-grid/time-grid.tsx | 191 + .../components/schedule-calendar/index.ts | 1 + .../schedule-calendar/schedule-calendar.tsx | 138 + .../components/schedule-context-menu/index.ts | 1 - .../schedule-context-menu.tsx | 84 - .../schedule-list-context-menu.tsx | 1 + .../components/task-context-menu/index.ts | 1 + .../task-context-menu/task-context-menu.tsx | 84 + .../components/task-delete-dialog/index.ts | 1 + .../task-delete-dialog/task-delete-dialog.tsx | 98 + .../components/task-details-modal/index.ts | 1 + .../task-details-modal/task-details-modal.tsx | 69 + .../components/task-modal/index.ts | 1 + .../task-modal/recurrence-control.tsx | 131 + .../components/task-modal/task-modal.tsx | 214 + .../scheduled-tasks/hooks/use-calendar.ts | 104 + .../hooks/use-scheduled-tasks.ts | 197 + .../[workspaceId]/scheduled-tasks/layout.tsx | 3 - .../scheduled-tasks/scheduled-tasks.tsx | 499 +- .../utils/calendar-grid.test.ts | 96 + .../scheduled-tasks/utils/calendar-grid.ts | 172 + .../scheduled-tasks/utils/recurrence.test.ts | 195 + .../scheduled-tasks/utils/recurrence.ts | 207 + .../utils/schedule-events.test.ts | 189 + .../scheduled-tasks/utils/schedule-events.ts | 171 + .../[workspaceId]/settings/[section]/page.tsx | 2 +- .../settings/components/api-keys/api-keys.tsx | 17 +- .../components/byok/byok-key-manager.tsx | 24 +- .../settings/components/copilot/copilot.tsx | 25 +- .../credential-sets/credential-sets.tsx | 26 +- .../components/custom-tools/custom-tools.tsx | 12 +- .../inbox-enable-toggle.tsx | 19 +- .../mcp-server-form-modal.tsx | 2 +- .../settings/components/mcp/mcp.tsx | 17 +- .../manage-credits-modal.tsx | 14 +- .../remove-member-dialog.tsx | 34 +- .../workflow-mcp-servers.tsx | 24 +- .../[workspaceId]/settings/navigation.ts | 2 +- .../secrets/[credentialId]/secret-detail.tsx | 5 +- .../components/skill-modal/skill-modal.tsx | 16 +- .../workspace/[workspaceId]/skills/layout.tsx | 3 - .../workspace/[workspaceId]/skills/skills.tsx | 12 +- .../components/row-modal/row-modal.tsx | 18 +- .../[workspaceId]/tables/[tableId]/table.tsx | 68 +- .../workspace/[workspaceId]/tables/layout.tsx | 3 - .../workspace/[workspaceId]/tables/tables.tsx | 20 +- .../[workspaceId]/utils/commands-utils.ts | 6 - .../deploy-modal/components/chat/chat.tsx | 24 +- .../general/components/api-info-modal.tsx | 2 +- .../components/version-description-modal.tsx | 14 +- .../components/general/general.tsx | 38 +- .../components/deploy-modal/deploy-modal.tsx | 35 +- .../custom-tool-modal/custom-tool-modal.tsx | 38 +- .../w/[workflowId]/components/panel/panel.tsx | 25 +- .../log-row-context-menu.tsx | 4 +- .../components/output-panel/output-panel.tsx | 4 +- .../components/terminal/terminal.tsx | 4 +- .../settings-sidebar/settings-sidebar.tsx | 2 +- .../components/delete-modal/delete-modal.tsx | 226 +- .../workspace-header/workspace-header.tsx | 13 +- .../w/components/sidebar/sidebar.tsx | 4 +- apps/sim/background/schedule-execution.ts | 51 +- apps/sim/blocks/blocks/incidentio.ts | 2 +- apps/sim/blocks/blocks/mothership.ts | 18 +- .../emcn/components/calendar/calendar.tsx | 53 +- .../chip-copy-input/chip-copy-input.tsx | 71 + .../chip-date-picker/chip-date-picker.tsx | 51 +- .../emcn/components/chip-modal/chip-modal.tsx | 337 +- .../chip-textarea/chip-textarea.tsx | 12 +- .../chip-time-picker/chip-time-picker.tsx | 135 + .../emcn/components/chip/chip-chrome.ts | 8 + .../components/emcn/components/chip/chip.tsx | 12 +- .../dropdown-menu/dropdown-menu.tsx | 18 +- apps/sim/components/emcn/components/index.ts | 12 + .../emcn/components/toast/toast.tsx | 199 +- apps/sim/components/emcn/icons/calendar.tsx | 10 +- .../components/access-control.tsx | 31 +- .../components/data-drains-settings.tsx | 16 +- apps/sim/ee/whitelabeling/metadata.ts | 2 +- .../mothership/mothership-handler.test.ts | 4 +- .../handlers/mothership/mothership-handler.ts | 20 +- apps/sim/hooks/queries/schedules.ts | 66 +- .../sim/lib/api/contracts/mothership-chats.ts | 7 + apps/sim/lib/api/contracts/schedules.ts | 87 +- .../lib/copilot/request/lifecycle/headless.ts | 2 +- .../lib/copilot/request/lifecycle/start.ts | 2 +- .../tools/handlers/platform-actions.ts | 1 - apps/sim/lib/copilot/tools/mcp/definitions.ts | 4 +- .../lib/data-drains/sources/copilot-chats.ts | 2 +- .../lib/data-drains/sources/copilot-runs.ts | 2 +- apps/sim/lib/integrations/integrations.json | 4 +- apps/sim/lib/logs/get-trigger-options.ts | 4 +- apps/sim/lib/mothership/inbox/lifecycle.ts | 2 +- apps/sim/lib/mothership/inbox/response.ts | 2 +- .../lib/workflows/schedules/orchestration.ts | 152 +- apps/sim/lib/workflows/schedules/utils.ts | 42 + apps/sim/next.config.ts | 9 + apps/sim/package.json | 1 + apps/sim/public/llms.txt | 2 +- apps/sim/stores/terminal/console/store.ts | 2 +- package.json | 1 + ...0235_schedule_task_contexts_recurrence.sql | 3 + .../db/migrations/meta/0235_snapshot.json | 16447 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 6 + 169 files changed, 22356 insertions(+), 3490 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/copyable-value-field.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/layout.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/integrations/layout.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/layout.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/logs/layout.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal/schedule-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/calendar-event-chip.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/calendar-toolbar.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/month-grid.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/time-grid.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/schedule-calendar.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu/schedule-context-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog/task-delete-dialog.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-control.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/layout.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/skills/layout.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/tables/layout.tsx create mode 100644 apps/sim/components/emcn/components/chip-copy-input/chip-copy-input.tsx create mode 100644 apps/sim/components/emcn/components/chip-time-picker/chip-time-picker.tsx create mode 100644 packages/db/migrations/0235_schedule_task_contexts_recurrence.sql create mode 100644 packages/db/migrations/meta/0235_snapshot.json diff --git a/.claude/rules/constitution.md b/.claude/rules/constitution.md index 6881c060ee..62dd7df3c5 100644 --- a/.claude/rules/constitution.md +++ b/.claude/rules/constitution.md @@ -23,7 +23,8 @@ Sim is the **AI workspace** where teams build and run AI agents. Not a workflow | The product | "AI workspace" | "workflow tool", "automation platform", "agent framework" | | Building | "build agents", "create agents" | "create workflows" (unless describing the workflow module specifically) | | Visual builder | "workflow builder" or "visual builder" | "canvas", "graph editor" | -| Mothership | "Mothership" (capitalized) | "chat", "AI assistant", "copilot" | +| The agent | "Sim" — you talk to Sim | "Mothership", "copilot", "AI assistant" | +| The chat surface | "Chat" (capitalized, the module) | "Mothership", "copilot" | | Deployment | "deploy", "ship" | "publish", "activate" | | Audience | "teams", "builders" | "users", "customers" (in marketing copy) | | What agents do | "automate real work" | "automate tasks", "automate workflows" | @@ -50,7 +51,7 @@ When describing Sim, always lead with the most differentiated claim: | Module | One-liner | |--------|-----------| -| **Mothership** | Your AI command center. Build and manage everything in natural language. | +| **Chat** | Your AI command center. Talk to Sim — build and manage everything in natural language. | | **Workflows** | The visual builder. Connect blocks, models, and integrations into agent logic. | | **Knowledge Base** | Your agents' memory. Upload docs, sync sources, build vector databases. | | **Tables** | A database, built in. Store, query, and wire structured data into agent runs. | @@ -65,7 +66,8 @@ When describing Sim, always lead with the most differentiated claim: - Never promise unshipped features - Never use jargon ("RAG", "vector database", "MCP") without plain-English explanation on public pages - Avoid "agentic workforce" as a primary term — use "AI agents" +- Never say "Mothership" or "copilot" — the agent is "Sim", the surface is "Chat" (in run logs the trigger reads "Sim agent") ## Vision -Sim becomes the default environment where teams build AI agents — not a tool you visit for one task, but a workspace you live in. Workflows are one module; Mothership is another. The workspace is the constant; the interface adapts. +Sim becomes the default environment where teams build AI agents — not a tool you visit for one task, but a workspace you live in. Workflows are one module; Chat is another. The workspace is the constant; the interface adapts. diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index 9adf86506b..9e096c2a9b 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -22,13 +22,15 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items - **`Chip` / `ChipLink`** — the pill button (` - - {copied ? 'Copied!' : copyLabel} - - } - /> - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx index d0d6569129..e13c3ce768 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx @@ -19,7 +19,7 @@ export function UnsavedChangesModal({ open, onOpenChange, onDiscard }: UnsavedCh onOpenChange={onOpenChange} srTitle='Unsaved Changes' title='Unsaved Changes' - description='You have unsaved changes. Are you sure you want to discard them?' + text='You have unsaved changes. Are you sure you want to discard them?' dismissLabel='Keep editing' confirm={{ label: 'Discard Changes', onClick: onDiscard }} /> diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts index 0c9b0befe5..3c076b766f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts @@ -1,6 +1,5 @@ export { AddPeopleModal } from './components/add-people-modal' export { CHIP_FIELD_INPUT, CHIP_FIELD_SHELL } from './components/chip-field' -export { CopyableValueField } from './components/copyable-value-field' export { CredentialDetailHeading } from './components/credential-detail-heading' export { CredentialDetailLayout } from './components/credential-detail-layout' export { CredentialMembersSection } from './components/credential-members-section' diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index dd30b61d6d..4c9c20a7d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -3,6 +3,7 @@ export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' export { InlineRenameInput } from './inline-rename-input' export { MessageActions } from './message-actions' +export { FloatingOverflowText } from './resource/components/floating-overflow-text' export { ownerCell } from './resource/components/owner-cell' export { type ChromeActionSpec, diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx index 5328e163c4..5dbb765637 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx @@ -86,7 +86,7 @@ export function ResourceChromeFallback({ sort={hasSort ? { options: [], active: null, onSort: noop } : undefined} filter={hasFilter ? { content: null } : undefined} /> - {columns ? : null} + {columns ? : null} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 5ca4538f5f..b544c525ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -13,6 +13,7 @@ import { createPortal } from 'react-dom' import { Chip, ChipChevronDown, + chipContentIconClass, chipGeometryClass, chipVariants, DropdownMenu, @@ -178,17 +179,21 @@ export const ResourceHeader = memo(function ResourceHeader({ ) }) ) : ( - - {TitleIcon && } + /** + * Root titles are short static labels ("Tables", "Files"), so the + * span is non-shrinkable and the label never truncates — matching + * the `shrink-0` guarantee the breadcrumb root crumb gets from + * {@link getBreadcrumbSegmentClassName}. Without this, the + * `flex-1` left column collapses during transient initial-load + * layout (the JS-driven `--sidebar-width` settling) and the title + * CSS-truncates to "T…" while the `shrink-0` actions hold width. + */ + + {TitleIcon && } {titleLabel && ( )} @@ -270,7 +275,7 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ if (editing?.isEditing) { return ( - {Icon && } + {Icon && } - {Icon && } + {Icon && } ) @@ -424,11 +429,11 @@ function BreadcrumbLocationPopover({ className )} > - - + + {rootBreadcrumb?.label && ( @@ -488,6 +493,18 @@ function LocationFocusVeil({ boundaryRef: React.RefObject }) { const [bounds, setBounds] = useState({ top: 0, left: 0 }) + /** + * Portal-mount gate. The veil must render `null` on BOTH the server render + * and the first client (hydration) render — branching on + * `typeof document === 'undefined'` made the two renders diverge, which + * failed hydration and forced React to regenerate the whole page tree on + * the client (a visible header flash during load). + */ + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) useEffect(() => { if (!visible) return @@ -510,7 +527,7 @@ function LocationFocusVeil({ } }, [boundaryRef, visible]) - if (typeof document === 'undefined') return null + if (!mounted) return null return createPortal(
- sortValues?: Record } export interface SelectableConfig { @@ -108,12 +103,19 @@ interface ResourceProps { * - `Resource.Options` — required, the search/filter/sort toolbar * - `Resource.Table` — optional; swap for any custom body (dashboard, grid, …) * - * The shell owns the fixed column layout; the children own their own chrome. + * Invariant: the shell renders identically for every consumer. Consumers supply + * content (columns, rows, cells) and behavior (handlers, configs) only — no + * prop changes the shell's chrome, spacing, or structure. The only sanctioned + * variation is replacing `Resource.Table` with a custom body. + * + * The shell owns the fixed column layout and is the positioning context for + * absolutely-positioned overlays (action bars, slide-out sidebars); the + * children own their own chrome. */ function ResourceRoot({ children, onContextMenu }: ResourceProps) { return (
{children} @@ -124,52 +126,50 @@ function ResourceRoot({ children, onContextMenu }: ResourceProps) { interface ResourceTableProps { columns: ResourceColumn[] rows: ResourceRow[] - defaultSort?: string - sort?: SortConfig selectedRowId?: string | null selectable?: SelectableConfig rowDragDrop?: RowDragDropConfig onRowClick?: (rowId: string) => void onRowHover?: (rowId: string) => void onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void - isLoading?: boolean onLoadMore?: () => void hasMore?: boolean isLoadingMore?: boolean pagination?: PaginationConfig - emptyMessage?: string + /** + * Sanctioned overlay slot. Rendered absolutely against the table region + * (action bars, slide-out sidebars, drop targets). The overlay owns its own + * chrome and positioning; it never alters the table's rendering. + */ overlay?: ReactNode } /** * Data table body, module-private and exposed only as `Resource.Table` — the * compound member is the sole way consumers render it. + * + * Chrome guarantee: the ``, ``, and column headers render + * unconditionally — no prop or row state (empty, loading, error) ever drops + * them. Structural additions (checkbox column, load-more sentinel, pagination + * bar) are driven purely by which configs the consumer supplies and always + * render the canonical chrome. */ const ResourceTable = memo(function ResourceTable({ columns, rows, - defaultSort, - sort: externalSort, selectedRowId, selectable, rowDragDrop, onRowClick, onRowHover, onRowContextMenu, - isLoading, onLoadMore, hasMore, isLoadingMore, pagination, - emptyMessage, overlay, }: ResourceTableProps) { const loadMoreRef = useRef(null) - const sortEnabled = defaultSort != null - const [internalSort, setInternalSort] = useState<{ column: string; direction: 'asc' | 'desc' }>({ - column: defaultSort ?? '', - direction: 'desc', - }) const [contextMenuRowId, setContextMenuRowId] = useState(null) @@ -201,24 +201,6 @@ const ResourceTable = memo(function ResourceTable({ } }, [contextMenuRowId]) - const handleSort = useCallback((column: string, direction: 'asc' | 'desc') => { - setInternalSort({ column, direction }) - }, []) - - const displayRows = useMemo(() => { - if (!sortEnabled || externalSort) return rows - return [...rows].sort((a, b) => { - const col = internalSort.column - const aVal = a.sortValues?.[col] ?? a.cells[col]?.label ?? '' - const bVal = b.sortValues?.[col] ?? b.cells[col]?.label ?? '' - const cmp = - typeof aVal === 'number' && typeof bVal === 'number' - ? aVal - bVal - : String(aVal).localeCompare(String(bVal)) - return internalSort.direction === 'asc' ? -cmp : cmp - }) - }, [rows, internalSort, sortEnabled, externalSort]) - useEffect(() => { if (!onLoadMore || !hasMore) return const el = loadMoreRef.current @@ -242,22 +224,9 @@ const ResourceTable = memo(function ResourceTable({ [selectable] ) - /** - * While loading, the table chrome (column headers) renders with an empty body - * and the rows "just load in" — never a skeleton, and never a false - * empty-state (the empty message is gated on `!isLoading`). - */ - if (!isLoading && rows.length === 0 && emptyMessage) { - return ( -
- {emptyMessage} -
- ) - } - return (
-
+
@@ -273,41 +242,18 @@ const ResourceTable = memo(function ResourceTable({ /> )} - {columns.map((col) => { - if (!sortEnabled) { - return ( - - ) - } - const isActive = internalSort.column === col.id - const SortIcon = internalSort.direction === 'asc' ? ArrowUp : ArrowDown - return ( - - ) - })} + {columns.map((col) => ( + + ))} - {displayRows.map((row) => ( + {rows.map((row) => ( - Are you sure you want to delete{' '} - {fileName ? ( - {fileName} - ) : ( - `${totalCount} item${totalCount === 1 ? '' : 's'}` - )} - ? {consequence} - - } + text={[ + 'Are you sure you want to delete ', + fileName + ? { text: fileName, bold: true } + : `${totalCount} item${totalCount === 1 ? '' : 's'}`, + `? ${consequence}`, + ]} confirm={{ label: 'Delete', onClick: onDelete, diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 7796b8d6ff..b4ff6d2706 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -190,8 +190,7 @@ export function Files() { }, [permissionConfig.hideFilesTab, router, workspaceId]) const { data: files = EMPTY_WORKSPACE_FILES, isLoading, error } = useWorkspaceFiles(workspaceId) - const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS, isLoading: foldersLoading } = - useWorkspaceFileFolders(workspaceId) + const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) const uploadFile = useUploadWorkspaceFile() const deleteFile = useDeleteWorkspaceFile() @@ -437,14 +436,6 @@ export function Files() { owner: ownerCell(folder.userId, members), updated: timeCell(folder.updatedAt), }, - sortValues: { - name: folder.name, - size: folderSizeMap.get(folder.id) ?? -1, - type: 'Folder', - created: new Date(folder.createdAt).getTime(), - updated: new Date(folder.updatedAt).getTime(), - owner: members?.find((m) => m.userId === folder.userId)?.name ?? '', - }, })) const fileRows = filteredFiles.map((file) => { @@ -467,14 +458,6 @@ export function Files() { owner: ownerCell(file.uploadedBy, members), updated: timeCell(file.updatedAt), }, - sortValues: { - name: file.name, - size: file.size, - type: formatFileType(file.type, file.name), - created: new Date(file.uploadedAt).getTime(), - updated: new Date(file.updatedAt).getTime(), - owner: members?.find((m) => m.userId === file.uploadedBy)?.name ?? '', - }, } return row }) @@ -1687,12 +1670,6 @@ export function Files() { const hasActiveFilters = typeFilter.length > 0 || sizeFilter.length > 0 || uploadedByFilter.length > 0 - const emptyMessage = debouncedSearchTerm - ? `No files match "${debouncedSearchTerm}"` - : hasActiveFilters - ? 'No files match the active filters' - : undefined - const filterContent = useMemo(() => { const typeDisplayLabel = typeFilter.length === 0 @@ -1844,19 +1821,19 @@ export function Files() { if (fileIdFromRoute && !selectedFile && isLoading) { return ( -
+
-
+ ) } if (selectedFile) { return ( <> -
+ -
+ {children} -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx index 0e5bcd3c98..208a358b97 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx @@ -85,7 +85,7 @@ export function ThinkingBlock({
- Mothership + Sim void + /** Opens the menu anchored at a viewport position (caret or trigger rect). */ + open: (anchor: { left: number; top: number }, options?: { mention?: boolean }) => void close: () => void moveActive: (delta: number) => void selectActive: () => boolean @@ -46,23 +47,27 @@ export interface PlusMenuHandle { /** * Box and typography shared by the textarea and its mirror overlay — both must * produce identical line wrapping so the overlay text sits exactly over the - * (transparent) textarea text. + * (transparent) textarea text. The scale is the canonical chip text-field + * scale ({@link ChipTextarea}: `text-sm`, default tracking), so the editor + * reads identically in the chat input and inside chip modals — one size, + * everywhere. */ const FIELD_MIRROR_CLASSES = cn( - 'm-0 box-border min-h-[24px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent', - 'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]' + 'm-0 box-border min-h-[20px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent', + 'px-1 py-1 font-body text-sm leading-[20px]' ) /** * The textarea grows to its full content height (`h-auto`, no internal scroll); * the shared scroller clips and scrolls it. Its text is transparent so the - * mirror overlay shows through; only the caret paints. + * mirror overlay shows through; only the caret paints. The placeholder uses + * the canonical `--text-muted`, matching every other chip text field. */ export const TEXTAREA_BASE_CLASSES = cn( FIELD_MIRROR_CLASSES, 'block h-auto resize-none overflow-hidden', 'text-transparent caret-[var(--text-primary)] outline-none', - 'placeholder:font-[380] placeholder:text-[var(--text-subtle)]', + 'placeholder:text-[var(--text-muted)]', 'focus-visible:ring-0 focus-visible:ring-offset-0' ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts index 9ea5a44d42..bf00d07920 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts @@ -25,6 +25,13 @@ export { DropOverlay } from './drop-overlay' export { MicButton } from './mic-button' export type { AvailableResourceGroup } from './plus-menu-dropdown' export { PlusMenuDropdown } from './plus-menu-dropdown' +export type { + PromptEditorInstance, + PromptEditorKeyPolicy, + PromptEditorProps, + UsePromptEditorProps, +} from './prompt-editor' +export { PromptEditor, usePromptEditor } from './prompt-editor' export { SendButton } from './send-button' export type { SkillsMenuHandle } from './skills-menu-dropdown/skills-menu-dropdown' export { SkillsMenuDropdown } from './skills-menu-dropdown/skills-menu-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index 6ca744fcea..530379f1db 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -10,9 +10,8 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, - Tooltip, } from '@/components/emcn' -import { Plus, Workflow } from '@/components/emcn/icons' +import { Workflow } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { buildFileFolderTree, @@ -57,19 +56,12 @@ export const PlusMenuDropdown = React.memo( const [search, setSearch] = useState('') const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null) const [activeIndex, setActiveIndex] = useState(0) - const buttonRef = useRef(null) const searchRef = useRef(null) const contentRef = useRef(null) const doOpen = useCallback( - (anchor?: { left: number; top: number }, options?: { mention?: boolean }) => { - if (anchor) { - setAnchorPos(anchor) - } else { - const rect = buttonRef.current?.getBoundingClientRect() - if (!rect) return - setAnchorPos({ left: rect.left, top: rect.top }) - } + (anchor: { left: number; top: number }, options?: { mention?: boolean }) => { + setAnchorPos(anchor) setIsMention(!!options?.mention) setOpen(true) setSearch('') @@ -252,162 +244,146 @@ export const PlusMenuDropdown = React.memo( } return ( - <> - - -
+ +
+ + + {!isMention && ( + { + setSearch(e.target.value) + setActiveIndex(0) }} + onKeyDown={handleSearchKeyDown} /> - - - {!isMention && ( - { - setSearch(e.target.value) - setActiveIndex(0) - }} - onKeyDown={handleSearchKeyDown} - /> - )} -
- {/* Always-mounted; swapping this subtree with filtered results makes Radix's + )} +
+ {/* Always-mounted; swapping this subtree with filtered results makes Radix's menu FocusScope steal focus from the search input back to the content root. */} - - - - - - - - Add resources - - + {/* Plain buttons, not DropdownMenuItem: mount/unmount must not mutate Radix's + menu Collection, or FocusScope restores focus to the content root. */} + {filteredItems !== null && + (filteredItems.length > 0 ? ( + filteredItems.map(({ type, item }, index) => { + const config = getResourceConfig(type) + const isActive = index === activeIndex + return ( + + ) + }) + ) : ( +
+ No results +
+ ))} +
+ + ) }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/index.ts new file mode 100644 index 0000000000..6b3e30415d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/index.ts @@ -0,0 +1,7 @@ +export { PromptEditor, type PromptEditorProps } from './prompt-editor' +export { + type PromptEditorInstance, + type PromptEditorKeyPolicy, + type UsePromptEditorProps, + usePromptEditor, +} from './use-prompt-editor' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx new file mode 100644 index 0000000000..b2633d2884 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -0,0 +1,204 @@ +'use client' + +import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react' +import { cn } from '@/lib/core/utils/cn' +import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' +import { + OVERLAY_CLASSES, + SCROLLER_CLASSES, + TEXTAREA_BASE_CLASSES, +} from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' +import { PlusMenuDropdown } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown' +import type { + PromptEditorInstance, + PromptEditorKeyPolicy, +} from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor' +import { SkillsMenuDropdown } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown' +import { + computeMentionHighlightRanges, + extractContextTokens, + stripMentionTrigger, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' + +export interface PromptEditorProps extends PromptEditorKeyPolicy { + /** Editor instance from {@link usePromptEditor}. */ + editor: PromptEditorInstance + /** Placeholder shown while the editor is empty. */ + placeholder?: string + /** Focuses the editor (caret at end) on mount. */ + autoFocus?: boolean + /** + * Layout/sizing only — a height cap (`max-h-[200px]`) or fill (`flex-1`) + * for the scroll container. The text chrome is owned by the editor. + */ + className?: string + /** Accessible label for the textarea. */ + 'aria-label'?: string +} + +/** + * The rendered face of {@link usePromptEditor}: a transparent-text textarea + * under a mirror overlay that paints mention chips (icon + label) in place of + * their tokens, plus the caret-anchored `@`-resource and `/`-skill menus. The + * textarea grows to its content height inside a single scroller, so overlay + * and caret co-scroll natively and never drift. + * + * Everything intrinsic to editing lives here; host-specific keys (Enter + * submit, ArrowUp history) are threaded in via {@link PromptEditorKeyPolicy}. + * + * @example + * ```tsx + * const editor = usePromptEditor({ workspaceId }) + * + * ``` + */ +export function PromptEditor({ + editor, + placeholder, + autoFocus = false, + className, + 'aria-label': ariaLabel, + onSubmit, + onArrowUpOnEmpty, +}: PromptEditorProps) { + const { textareaRef, value } = editor + + useLayoutEffect(() => { + const textarea = textareaRef.current + if (!textarea) return + // Grow the textarea to its full content height; the scroller caps the + // visible height and scrolls textarea + overlay together natively. + textarea.style.height = 'auto' + textarea.style.height = `${textarea.scrollHeight}px` + }, [value, textareaRef]) + + useEffect(() => { + if (autoFocus) editor.focusAtEnd() + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only focus + }, []) + + /** + * Clicking the editor's empty regions (padding, space below the last line) + * focuses the textarea; clicks on the textarea itself keep native caret + * placement. + */ + const handleSurfaceClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === textareaRef.current) return + if ((e.target as HTMLElement).closest('button')) return + textareaRef.current?.focus() + }, + [textareaRef] + ) + + const overlayContent = useMemo(() => { + const contexts = editor.contexts + + if (!value) { + return {'\u00A0'} + } + + if (contexts.length === 0) { + const displayText = value.endsWith('\n') ? `${value}\u200B` : value + return {displayText} + } + + const tokens = extractContextTokens(contexts) + const ranges = computeMentionHighlightRanges(value, tokens) + + if (ranges.length === 0) { + const displayText = value.endsWith('\n') ? `${value}\u200B` : value + return {displayText} + } + + const elements: React.ReactNode[] = [] + let lastIndex = 0 + for (let i = 0; i < ranges.length; i++) { + const range = ranges[i] + + if (range.start > lastIndex) { + const before = value.slice(lastIndex, range.start) + elements.push({before}) + } + + const mentionLabel = stripMentionTrigger(range.token) + const matchingCtx = contexts.find((c) => c.label === mentionLabel) + + const mentionIconNode = matchingCtx ? ( + + ) : null + + elements.push( + + + {/* Invisible trigger glyph keeps the overlay's advance identical to + the transparent textarea; the icon centers over its slot. */} + {range.token.charAt(0)} + {mentionIconNode} + + {mentionLabel} + + ) + lastIndex = range.end + } + + const tail = value.slice(lastIndex) + if (tail) { + const displayTail = tail.endsWith('\n') ? `${tail}\u200B` : tail + elements.push({displayTail}) + } + + return elements.length > 0 ? elements : {'\u00A0'} + }, [value, editor.contexts]) + + return ( +
+ {/* Sizer for textarea + overlay: the textarea grows to full content + height and the overlay fills it via `inset-0`, so both are flow + children of the same scroller and co-scroll natively. */} +
+ + +
- {col.header} - - - + {col.header} +