Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .claude/rules/sim-list-ordering.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: List and menu ordering that mirrors the sidebar or toolbar, with one separator before the destructive action
description: List and menu ordering that mirrors the toolbar or settings nav, encoded once, with one separator before the destructive action
paths:
- "apps/sim/app/**/*.tsx"
- "apps/sim/ee/**/*.tsx"
Expand All @@ -8,7 +8,7 @@ paths:

# List & Menu Ordering

**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in the sidebar, in a toolbar, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time.
**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in a toolbar, in the settings nav, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time.

This is not a style preference. Order is the cheapest affordance a list has, and the only one that costs nothing to get right.

Expand All @@ -18,13 +18,14 @@ Before writing a list of items, find where the user sees those same items *first

| The list | Mirrors |
| --- | --- |
| Resource menus (`+` attach, `@` mention, resource-tab `+`) | the workspace **sidebar**, top-down |
| A row / root **context menu** | that surface's **toolbar**, left-to-right → top-to-bottom |
| Settings tab strip, recently-deleted tabs | the **settings nav**, top-down |
| A "New …" menu | the order those things appear once created |

Left-to-right becomes top-to-bottom. A toolbar reading `Filter · Sort · Export · Delete` becomes a menu reading Filter, Sort, Export, Delete — never alphabetized, never grouped by implementation, never "destructive last" unless the toolbar already puts it last.

Resource menus (`+` attach, `@` mention, resource-tab `+`) do not mirror the sidebar. Their order is a product decision encoded in `RESOURCE_MENU_ORDER` (see below), and every resource menu shares it.

Platform-only entries (desktop **Browser** and **Terminal**) trail the shared set rather than interleaving, so the common prefix is identical on every platform.

## Grouping: a rule marks a change in what the action acts on
Expand Down Expand Up @@ -107,10 +108,10 @@ grouping wants the standard grouping.
An order duplicated across surfaces is an order that will drift. Export **one** constant and sort by it — do not hand-maintain a matching literal per menu.

```ts
/** Top-down order for every menu listing resource families, mirroring the sidebar. */
/** Top-down order for every menu listing resource families. */
export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [
'integration', 'task', 'table', 'file', 'filefolder',
'knowledgebase', 'log', 'workflow', 'folder', 'browser', 'terminal', 'generic',
'integration', 'task', 'dashboard', 'table', 'file', 'filefolder',
'knowledgebase', 'workflow', 'log', 'folder', 'browser', 'terminal', 'generic',
]

export function byResourceMenuOrder<T extends { type: MothershipResourceType }>(a: T, b: T) {
Expand Down
13 changes: 7 additions & 6 deletions .cursor/rules/sim-list-ordering.mdc
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
---
description: "List and menu ordering that mirrors the sidebar or toolbar, with one separator before the destructive action"
description: "List and menu ordering that mirrors the toolbar or settings nav, encoded once, with one separator before the destructive action"
globs: ["apps/sim/app/**/*.tsx","apps/sim/ee/**/*.tsx","apps/sim/components/**/*.tsx"]
---

<!-- Generated from .claude/rules/sim-list-ordering.md by `bun run skills:sync`. Edit the source, not this file. -->

# List & Menu Ordering

**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in the sidebar, in a toolbar, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time.
**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in a toolbar, in the settings nav, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time.

This is not a style preference. Order is the cheapest affordance a list has, and the only one that costs nothing to get right.

Expand All @@ -17,13 +17,14 @@ Before writing a list of items, find where the user sees those same items *first

| The list | Mirrors |
| --- | --- |
| Resource menus (`+` attach, `@` mention, resource-tab `+`) | the workspace **sidebar**, top-down |
| A row / root **context menu** | that surface's **toolbar**, left-to-right → top-to-bottom |
| Settings tab strip, recently-deleted tabs | the **settings nav**, top-down |
| A "New …" menu | the order those things appear once created |

Left-to-right becomes top-to-bottom. A toolbar reading `Filter · Sort · Export · Delete` becomes a menu reading Filter, Sort, Export, Delete — never alphabetized, never grouped by implementation, never "destructive last" unless the toolbar already puts it last.

Resource menus (`+` attach, `@` mention, resource-tab `+`) do not mirror the sidebar. Their order is a product decision encoded in `RESOURCE_MENU_ORDER` (see below), and every resource menu shares it.

Platform-only entries (desktop **Browser** and **Terminal**) trail the shared set rather than interleaving, so the common prefix is identical on every platform.

## Grouping: a rule marks a change in what the action acts on
Expand Down Expand Up @@ -106,10 +107,10 @@ grouping wants the standard grouping.
An order duplicated across surfaces is an order that will drift. Export **one** constant and sort by it — do not hand-maintain a matching literal per menu.

```ts
/** Top-down order for every menu listing resource families, mirroring the sidebar. */
/** Top-down order for every menu listing resource families. */
export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [
'integration', 'task', 'table', 'file', 'filefolder',
'knowledgebase', 'log', 'workflow', 'folder', 'browser', 'terminal', 'generic',
'integration', 'task', 'dashboard', 'table', 'file', 'filefolder',
'knowledgebase', 'workflow', 'log', 'folder', 'browser', 'terminal', 'generic',
]

export function byResourceMenuOrder<T extends { type: MothershipResourceType }>(a: T, b: T) {
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ The `'use client'` server boundary, the app/worker runtime env split, and featur
- **Components**: `'use client'` only for hooks or browser APIs. Structure order, extraction thresholds, and list-render rules: `.claude/rules/sim-components.md`. Render-performance idioms (lazy-init refs, hoisting, `Map` pre-indexing, `[...arr].sort()` never `toSorted()` on client paths): `.claude/rules/sim-react-performance.md`. For effect/state/memo/callback anti-patterns use the `/you-might-not-need-*` skills and verify against the running UI.
- **State ownership**: React Query owns server data — never `useState` + `fetch`; shareable client view-state (tabs, filters, search, pagination, selected id) lives in the URL via `nuqs`; Zustand owns global client state; `useState` owns UI-only state. Hooks: `.claude/rules/sim-hooks.md`. Stores (`devtools`, `persist` only with an explicit `partialize` whitelist, workflow value invariants): `.claude/rules/sim-stores.md`. URL state: `.claude/rules/sim-url-state.md`.
- **Utils**: inline a helper with one consumer; create `utils.ts` when 2+ files share it — in `lib/` (app-wide) or `feature/utils/` (feature-scoped). Check `lib/` before writing a new one.
- **Lists and menus** mirror the order the user already reads elsewhere (sidebar, toolbar), encoded in one exported order constant; a separator marks only a change in what the action acts on (typically one, before the destructive action): `.claude/rules/sim-list-ordering.md`.
- **Lists and menus** mirror the order the user already reads elsewhere (toolbar, settings nav), encoded in one exported order constant (resource menus share `RESOURCE_MENU_ORDER`, a product order that does not mirror the sidebar); a separator marks only a change in what the action acts on (typically one, before the destructive action): `.claude/rules/sim-list-ordering.md`.
- **Caching**: `lru-cache` with a `max` ceiling, never a hand-rolled TTL `Map`; a lifecycle map is not a cache; cache the gate, never the credential: `.claude/rules/sim-caching.md`.

## API Contracts and Routes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,17 +289,16 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
export const MENTION_PREVIEW_DEFAULT_LIMIT = 5

/**
* Top-down order for every menu that lists resource families, mirroring the
* workspace sidebar so a user reads the same sequence in both places. The two
* desktop-only panels trail the workspace resources, matching where they surface
* in the app. `folder`/`filefolder` never render as their own entry — they feed
* Top-down order for every menu that lists resource families (`+` attach, `@`
* mention, resource-tab `+`). It is its own product order, not a copy of the
* sidebar. The two desktop-only panels trail the workspace resources. `folder`/`filefolder` never render as their own entry — they feed
* their family's folder tree — but are ordered beside it so a menu that ever does
* surface them lands in the right place.
*/
export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [
'dashboard',
'integration',
'task',
'dashboard',
Comment thread
TheodoreSpeaks marked this conversation as resolved.
'table',
'file',
'filefolder',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3423,7 +3423,7 @@ export function useChat(
? { viewId: (c.currentView ? c.currentView.viewId : c.viewId) ?? undefined }
: {}),
...('fileId' in c && c.fileId ? { fileId: c.fileId } : {}),
...(c.kind === 'dashboard' ? { dashboardId: c.dashboardId } : {}),
...('dashboardId' in c && c.dashboardId ? { dashboardId: c.dashboardId } : {}),
...('folderId' in c && c.folderId ? { folderId: c.folderId } : {}),
...(c.kind === 'skill' && 'skillId' in c ? { skillId: c.skillId } : {}),
...(c.kind === 'integration' && 'blockType' in c ? { blockType: c.blockType } : {}),
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/api/contracts/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const dashboardRecordSchema = z.object({
type: z.literal('dashboard'),
name: z.string(),
updatedAt: z.string(),
revision: z.string(),
revision: dashboardRevisionSchema,
})

/** A workspace has at most one dashboard, which Sim builds; both fields are null until then. */
Expand All @@ -25,7 +25,7 @@ export const readWorkspaceDashboardContract = defineRouteContract({
mode: 'json',
schema: z.object({
dashboard: dashboardRecordSchema.nullable(),
content: z.string().nullable(),
content: dashboardContentSchema.nullable(),
}),
},
})
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/lib/dashboards/repository.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ describe('dashboard repository in PostgreSQL', () => {
})

it('updates only at the expected revision and advances it', async () => {
const current = (await insertWorkspaceDashboard('ws-c', 'first', 'user-1'))!
const current = await insertWorkspaceDashboard('ws-c', 'original', 'user-1')
if (!current) throw new Error('ws-c dashboard was not created')
const updated = await updateDashboardContent(current.id, 'edited', 'user-2', current.revision)
expect(updated).toMatchObject({
content: 'edited',
Expand Down
28 changes: 14 additions & 14 deletions apps/sim/lib/mothership/chat/display-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,20 @@ describe('display-message', () => {
])
})

it('keeps the dashboard id on a reopened dashboard mention', () => {
const display = toDisplayMessage({
id: 'msg-dashboard',
role: 'user',
content: '@Dashboard',
timestamp: '2024-01-01T00:00:00.000Z',
contexts: [{ kind: 'dashboard', label: 'Dashboard', dashboardId: 'dash-1' }],
})

expect(display.contexts).toEqual([
{ kind: 'dashboard', label: 'Dashboard', dashboardId: 'dash-1' },
])
})

it('preserves browser and terminal selection metadata for reopened messages', () => {
const display = toDisplayMessage({
id: 'msg-selection',
Expand Down Expand Up @@ -208,20 +222,6 @@ describe('display-message', () => {
])
})

it('keeps the dashboard id of a reopened dashboard mention', () => {
const display = toDisplayMessage({
id: 'msg-dashboard',
role: 'user',
content: '@Dashboard',
timestamp: '2024-01-01T00:00:00.000Z',
contexts: [{ kind: 'dashboard', label: 'Dashboard', dashboardId: 'dashboard-1' }],
})

expect(display.contexts).toEqual([
{ kind: 'dashboard', label: 'Dashboard', dashboardId: 'dashboard-1' },
])
})

it.each(['pending', 'executing', 'awaiting_approval'])(
'shows a %s row of a stored message as interrupted, not running',
(state) => {
Expand Down
30 changes: 9 additions & 21 deletions apps/sim/lib/mothership/chat/display-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import type { PersistedContentBlock } from '@/lib/api/contracts/copilot-messages
import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview'
import { isLiveAssistantMessageId } from '@/lib/mothership/chat/live-message-id'
import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message'
import { isUnsettledToolState, withBlockTiming } from '@/lib/mothership/chat/persisted-message'
import {
copyPersistedMessageContext,
isUnsettledToolState,
withBlockTiming,
} from '@/lib/mothership/chat/persisted-message'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
Expand Down Expand Up @@ -141,26 +145,10 @@ function toDisplayContexts(
contexts: PersistedMessage['contexts']
): ChatMessageContext[] | undefined {
if (!contexts || contexts.length === 0) return undefined
return contexts.map((c) => ({
kind: c.kind as ChatContextKind,
label: c.label,
...(c.workflowId ? { workflowId: c.workflowId } : {}),
...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
...(c.tableId ? { tableId: c.tableId } : {}),
...(c.viewId ? { viewId: c.viewId } : {}),
...(c.fileId ? { fileId: c.fileId } : {}),
...(c.dashboardId ? { dashboardId: c.dashboardId } : {}),
...(c.folderId ? { folderId: c.folderId } : {}),
...(c.chatId ? { chatId: c.chatId } : {}),
...(c.blockType ? { blockType: c.blockType } : {}),
...(c.skillId ? { skillId: c.skillId } : {}),
...(c.serverId ? { serverId: c.serverId } : {}),
...(c.fileName ? { fileName: c.fileName } : {}),
...(c.tableName ? { tableName: c.tableName } : {}),
...(c.tabId ? { tabId: c.tabId } : {}),
...(c.terminalId ? { terminalId: c.terminalId } : {}),
...(c.selection ? { selection: { ...c.selection } } : {}),
}))
return contexts.map((c) => {
const copy = copyPersistedMessageContext(c)
return { ...copy, kind: copy.kind as ChatContextKind }
})
}

const WORKSPACE_FILE_TOOL = 'prepare_file_edit'
Expand Down
17 changes: 17 additions & 0 deletions apps/sim/lib/mothership/chat/payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,23 @@ describe('buildCopilotRequestPayload', () => {
}
)

it('never grants the workspace-only dashboards entitlement to an organization chat', async () => {
mockDashboardAvailability.mockResolvedValue(true)
const payload = await buildCopilotRequestPayload(
{
message: 'Show my dashboard',
userId: 'actor',
userMessageId: 'message-1',
organizationId: 'org-1',
principal: { kind: 'session' as const, userId: 'actor' },
mode: 'agent',
model: '',
},
{ selectedModel: '' }
)
expect(payload.entitlements).toEqual([])
})

beforeEach(() => {
mockTrackChatUpload.mockResolvedValue({ displayName: 'payroll.xlsx' })
mockSecretNames.mockResolvedValue({ names: [] })
Expand Down
68 changes: 27 additions & 41 deletions apps/sim/lib/mothership/chat/persisted-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface PersistedFileAttachment {
size: number
}

interface PersistedMessageContext {
export interface PersistedMessageContext {
kind: string
label: string
workflowId?: string
Expand Down Expand Up @@ -87,6 +87,30 @@ function copyTextSelection(
}
}

/** The one field-wise copy of a message context, shared by every write, read and display path. */
export function copyPersistedMessageContext(c: PersistedMessageContext): PersistedMessageContext {
return {
kind: c.kind,
label: c.label,
...(c.workflowId ? { workflowId: c.workflowId } : {}),
...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
...(c.tableId ? { tableId: c.tableId } : {}),
...(c.viewId ? { viewId: c.viewId } : {}),
...(c.fileId ? { fileId: c.fileId } : {}),
...(c.dashboardId ? { dashboardId: c.dashboardId } : {}),
...(c.folderId ? { folderId: c.folderId } : {}),
...(c.chatId ? { chatId: c.chatId } : {}),
...(c.blockType ? { blockType: c.blockType } : {}),
...(c.skillId ? { skillId: c.skillId } : {}),
...(c.serverId ? { serverId: c.serverId } : {}),
...(c.fileName ? { fileName: c.fileName } : {}),
...(c.tableName ? { tableName: c.tableName } : {}),
...(c.tabId ? { tabId: c.tabId } : {}),
...(c.terminalId ? { terminalId: c.terminalId } : {}),
...(c.selection ? { selection: copyTextSelection(c.selection) } : {}),
}
}

export interface PersistedMessage {
id: string
role: 'user' | 'assistant'
Expand Down Expand Up @@ -464,26 +488,7 @@ export function buildPersistedUserMessage(params: UserMessageParams): PersistedM
}

if (params.contexts && params.contexts.length > 0) {
message.contexts = params.contexts.map((c) => ({
kind: c.kind,
label: c.label,
...(c.workflowId ? { workflowId: c.workflowId } : {}),
...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
...(c.tableId ? { tableId: c.tableId } : {}),
...(c.viewId ? { viewId: c.viewId } : {}),
...(c.fileId ? { fileId: c.fileId } : {}),
...(c.dashboardId ? { dashboardId: c.dashboardId } : {}),
...(c.folderId ? { folderId: c.folderId } : {}),
...(c.chatId ? { chatId: c.chatId } : {}),
...(c.blockType ? { blockType: c.blockType } : {}),
...(c.skillId ? { skillId: c.skillId } : {}),
...(c.serverId ? { serverId: c.serverId } : {}),
...(c.fileName ? { fileName: c.fileName } : {}),
...(c.tableName ? { tableName: c.tableName } : {}),
...(c.tabId ? { tabId: c.tabId } : {}),
...(c.terminalId ? { terminalId: c.terminalId } : {}),
...(c.selection ? { selection: copyTextSelection(c.selection) } : {}),
}))
message.contexts = params.contexts.map(copyPersistedMessageContext)
}

return message
Expand Down Expand Up @@ -816,26 +821,7 @@ export function normalizeMessage(raw: Record<string, unknown>): PersistedMessage

const rawContexts = raw.contexts as PersistedMessageContext[] | undefined
if (Array.isArray(rawContexts) && rawContexts.length > 0) {
msg.contexts = rawContexts.map((c) => ({
kind: c.kind,
label: c.label,
...(c.workflowId ? { workflowId: c.workflowId } : {}),
...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
...(c.tableId ? { tableId: c.tableId } : {}),
...(c.viewId ? { viewId: c.viewId } : {}),
...(c.fileId ? { fileId: c.fileId } : {}),
...(c.dashboardId ? { dashboardId: c.dashboardId } : {}),
...(c.folderId ? { folderId: c.folderId } : {}),
...(c.chatId ? { chatId: c.chatId } : {}),
...(c.blockType ? { blockType: c.blockType } : {}),
...(c.skillId ? { skillId: c.skillId } : {}),
...(c.serverId ? { serverId: c.serverId } : {}),
...(c.fileName ? { fileName: c.fileName } : {}),
...(c.tableName ? { tableName: c.tableName } : {}),
...(c.tabId ? { tabId: c.tabId } : {}),
...(c.terminalId ? { terminalId: c.terminalId } : {}),
...(c.selection ? { selection: copyTextSelection(c.selection) } : {}),
}))
msg.contexts = rawContexts.map(copyPersistedMessageContext)
}

return msg
Expand Down
Loading
Loading