Skip to content

Commit 0c2124a

Browse files
committed
fix(platform): keep drafts and interface edits across navigation
1 parent 7bc2e1d commit 0c2124a

40 files changed

Lines changed: 595 additions & 234 deletions

‎apps/sim/platform/AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Search Shared → Platform → Product before adding anything. Use the stable Pa
2929

3030
## Product UI and desktop
3131

32+
- Keep a draft or interface edit in memory for the lifetime its user expects. A chat draft belongs to its chat; profile edits and inbox read marks belong to the product session. Navigating away and back must preserve them. A new fixture scenario starts a fresh session. Popups and cancelled edits stay local to the control.
3233
- The same product entry under `app/(shell)/` serves the web browser and the existing `apps/desktop` Electron shell. Feature-detect `window.simDesktop` in the typed desktop adapter only. Consult the shared `@sim/desktop-bridge` contract and native titlebar observer. Electron owns real window controls and privileged operations. Reserve their inset only in a native window; keep interactive areas outside drag regions. Do not draw traffic lights, mock the bridge in product code, or fork pages by viewport or user agent.
3334
- On every shell or panel change, test the real Electron window as well as web, keyboard, narrow and intermediate layouts. Exercise inset/fullscreen, sidebar expand/collapse, panel takeover, tabs and focus return. Browser-only views must remain usable without the preload. Native capability integration remains Phase 3.
3435
- Keep unique content in its feature owner. A calendar event, attention row or two-line workspace summary can have its own anatomy. Reuse semantic ink, type, icon size, first-line alignment, row insets and spacing. Its overflow trigger still uses EMCN's icon button/menu; do not extract a generic row on resemblance alone. When a second feature needs the same semantic recipe, promote the original implementation and migrate both consumers.

‎apps/sim/platform/README.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ Start the Platform above, then launch the existing desktop shell using a disposa
2323
SIM_DESKTOP_ORIGIN=http://127.0.0.1:3100 SIM_DESKTOP_USER_DATA="$(mktemp -d)" bun run --cwd apps/desktop dev
2424
```
2525

26-
2726
## Using EMCN
2827

2928
Select a theme once with `PlatformSurface`. Editors, menus, selections, links and dialogs own their keyboard behavior. `MetricGroup` and `DataTable` accept records of any length. Import controls and compositions from `@/platform/design-system/components`. Provide content and named states, never styles. For a structural master such as an Issue group, supply its Paper key and named slots. The key determines the allowed slot names and the compiler enforces missing slots. A nested button slot accepts the semantic button, not an image of it. The Paper catalog in the workbench resolves the actual example.

‎apps/sim/platform/app/(shell)/_components/product-frame.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
22
import { ProductSurface } from '@/platform/design-system/components/product-surface'
33
import { PlatformShell } from '@/platform/design-system/features/shell/platform-shell'
44
import type { ShellData } from '@/platform/design-system/models/shell'
5+
import { readAppearance } from '@/platform/design-system/navigation'
56
import type { Scenario } from '@/platform/fixtures'
67

78
type ProductQuery = Record<string, string | string[] | undefined>
@@ -25,9 +26,8 @@ export function ProductFrame({
2526
navigation,
2627
desktopTitle,
2728
}: ProductFrameProps) {
28-
const appearance = query.theme === 'light' || query.theme === 'dark' ? query.theme : 'system'
2929
return (
30-
<ProductSurface preference={appearance}>
30+
<ProductSurface preference={readAppearance(query.theme)}>
3131
<PlatformShell data={shell} desktopTitle={desktopTitle} navigation={navigation}>
3232
{children}
3333
</PlatformShell>

‎apps/sim/platform/app/(shell)/_components/product-session.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import type { ReactNode } from 'react'
44
import { useSearchParams } from 'next/navigation'
55
import { InterfacePreferences } from '@/platform/design-system/components'
66
import { ChatChangesProvider } from '@/platform/design-system/components/chat-changes'
7+
import { ComposerDrafts } from '@/platform/design-system/components/composer-drafts'
78
import { DesktopHost } from '@/platform/design-system/components/desktop-host'
9+
import { NotificationSession } from '@/platform/design-system/components/notification-tray'
810
import { SidebarLayout } from '@/platform/design-system/components/sidebar-layout'
911

1012
interface ProductSessionProps {
@@ -18,7 +20,11 @@ export function ProductSession({ children }: ProductSessionProps) {
1820
<InterfacePreferences key={scenario}>
1921
<DesktopHost />
2022
<SidebarLayout>
21-
<ChatChangesProvider>{children}</ChatChangesProvider>
23+
<NotificationSession>
24+
<ChatChangesProvider>
25+
<ComposerDrafts>{children}</ComposerDrafts>
26+
</ChatChangesProvider>
27+
</NotificationSession>
2228
</SidebarLayout>
2329
</InterfacePreferences>
2430
)

‎apps/sim/platform/app/workbench/page.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import type { Metadata } from 'next'
12
import { loadPreviewData } from '@/platform/fixtures'
23
import { Workbench } from '@/platform/preview/workbench'
34

5+
export const metadata: Metadata = {
6+
title: 'Platform component workbench',
7+
description: 'Browse Sim’s shared components, themes and Paper recipes.',
8+
}
9+
410
interface WorkbenchPageProps {
511
searchParams: Promise<Record<string, string | string[] | undefined>>
612
}

‎apps/sim/platform/checks/conversation.spec.ts‎

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,24 @@ test('a new chat carries its local draft and attachments through navigation with
4949
})
5050
const text = page.getByRole('textbox', { name: /Ask about your business/ })
5151
await text.fill('/')
52-
await page.getByRole('menuitem', { name: 'Chats', exact: true }).hover()
52+
const chatsMenu = page.getByRole('menuitem', { name: 'Chats', exact: true })
53+
await expect(chatsMenu).toBeVisible()
54+
await page.keyboard.type('notes')
55+
await expect(text).toHaveValue('/notes')
56+
await expect(chatsMenu).toHaveCount(0)
57+
await text.fill('/')
58+
await chatsMenu.hover()
5359
await page.getByRole('menuitem', { name: 'Review the renewal brief' }).click()
5460
await page.getByRole('button', { name: 'Remove Review the renewal brief' }).click()
55-
await text.fill('Draft the meeting recap')
56-
await text.press('Enter')
61+
await page.getByRole('radio', { name: 'Search', exact: true }).check()
62+
const search = page.getByRole('textbox', { name: /Search your company/ })
63+
await search.fill('Draft the meeting recap')
64+
await page.getByRole('link', { name: 'Q4 planning', exact: true }).click()
65+
await expect(page.getByRole('heading', { name: 'Q4 planning' })).toBeVisible()
66+
await page.getByRole('link', { name: 'Home', exact: true }).click()
67+
await expect(page.getByRole('radio', { name: 'Search', exact: true })).toBeChecked()
68+
await expect(search).toHaveValue('Draft the meeting recap')
69+
await search.press('Enter')
5770
await expect(page).toHaveURL(/\/chat\/[^?]+\?/)
5871
expect(page.url()).not.toContain('meeting')
5972
await expect(page.getByRole('heading', { name: 'Draft the meeting recap' })).toBeVisible()
@@ -107,3 +120,62 @@ test('stress transcript keeps attachment review bounded and returns the reader t
107120
await page.getByRole('button', { name: 'Jump to latest' }).click()
108121
await expect(page.getByRole('button', { name: 'Jump to latest' })).toBeHidden()
109122
})
123+
124+
test('a follow-up leaves the active run stoppable', async ({ page }) => {
125+
await page.goto(`${populated}/chat/customer-onboarding?state=full`)
126+
const editor = page.getByRole('textbox', { name: 'Add a follow-up…' })
127+
await editor.fill('Also include the handoff')
128+
await editor.press('Enter')
129+
await expect(page.getByText('Also include the handoff', { exact: true })).toBeVisible()
130+
await page.getByRole('button', { name: 'Stop response' }).click()
131+
await expect(page.getByText('Stopped', { exact: true })).toBeVisible()
132+
await expect(page.getByRole('button', { name: 'Stop response' })).toHaveCount(0)
133+
})
134+
135+
test('unsent drafts and files stay with their chat through navigation and clear after sending', async ({
136+
page,
137+
}) => {
138+
await page.goto(`${populated}/chat/weekly-review?state=full`)
139+
const editor = page.getByRole('textbox', { name: 'Ask a follow-up…' })
140+
await editor.fill('Review before sending')
141+
await page.getByRole('button', { name: 'Add attachment' }).click()
142+
const chooser = page.waitForEvent('filechooser')
143+
await page.getByRole('menuitem', { name: 'Upload files' }).click()
144+
await (await chooser).setFiles({
145+
name: 'handoff.txt',
146+
mimeType: 'text/plain',
147+
buffer: Buffer.from('Available offline'),
148+
})
149+
await page.getByRole('link', { name: 'Q4 planning' }).click()
150+
await expect(editor).toBeEmpty()
151+
await expect(page.getByRole('button', { name: 'Preview handoff.txt' })).toHaveCount(0)
152+
await page.getByRole('link', { name: 'Review the renewal brief', exact: true }).click()
153+
await expect(editor).toHaveValue('Review before sending')
154+
await page.getByRole('button', { name: 'Preview handoff.txt' }).click()
155+
await expect(page.getByRole('dialog').getByText('Available offline')).toBeVisible()
156+
await page.keyboard.press('Escape')
157+
await editor.press('Enter')
158+
await expect(page.getByText('Review before sending', { exact: true })).toBeVisible()
159+
await page.getByRole('link', { name: 'Q4 planning' }).click()
160+
await page.getByRole('link', { name: 'Review the renewal brief', exact: true }).click()
161+
await expect(editor).toBeEmpty()
162+
await expect(page.getByRole('button', { name: 'Remove handoff.txt' })).toHaveCount(0)
163+
})
164+
165+
test.describe('touch composer', () => {
166+
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
167+
168+
test('Return inserts a newline and sending consumes the original attachment batch', async ({
169+
page,
170+
}) => {
171+
await page.goto(`${populated}/chat?state=stress`)
172+
const editor = page.getByRole('textbox', { name: /Ask about your business/ })
173+
await editor.fill('Review the attached files')
174+
await editor.press('Enter')
175+
await expect(editor).toHaveValue('Review the attached files\n')
176+
await page.getByRole('button', { name: 'Send message' }).click()
177+
await expect(page.getByRole('heading', { name: 'Review the attached files' })).toBeVisible()
178+
await expect(page.getByRole('button', { name: /20 attachments.*Show all/ })).toHaveCount(1)
179+
await expect(page.getByRole('button', { name: 'Send message' })).toBeDisabled()
180+
})
181+
})

‎apps/sim/platform/checks/panels.spec.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ test('a chat panel preserves its file, tabs and focus across resize, collapse an
2323
await expect(
2424
page.getByRole('heading', { level: 1, name: 'Review the renewal brief' })
2525
).toBeVisible()
26+
await panel.getByRole('button', { name: 'New panel tab' }).click()
27+
const lastClose = panel.getByRole('button', { name: 'Close Workspaces tab' }).last()
28+
await lastClose.focus()
29+
await page.keyboard.press('Enter')
30+
await expect(panel.getByRole('tab', { selected: true })).toBeFocused()
31+
await panel.getByRole('tab', { name: 'Renewal brief' }).click()
2632
await page.setViewportSize({ width: 673, height: 823 })
2733
const opener = await page.getByRole('button', { name: 'Expand sidebar' }).boundingBox()
2834
const add = await panel.getByRole('button', { name: 'New panel tab' }).boundingBox()

‎apps/sim/platform/checks/settings.spec.ts‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ const base = 'http://127.0.0.1:3181'
55
test('preferences apply to the interface and survive settings navigation in this session', async ({
66
page,
77
}) => {
8-
await page.goto(`${base}/settings?theme=light&state=full`)
9-
await page.getByRole('combobox', { name: 'Theme', exact: true }).click()
8+
await page.goto(`${base}/settings?theme=unrecognized&state=full`)
9+
const theme = page.getByRole('combobox', { name: 'Theme', exact: true })
10+
await expect(theme).toHaveText('System')
11+
await theme.click()
1012
await page.getByRole('option', { name: 'Dark', exact: true }).click()
1113
await expect(page.locator('[data-platform-ui]')).toHaveAttribute('data-theme', 'dark')
1214
await page.getByRole('combobox', { name: 'Interface font' }).click()
@@ -52,7 +54,7 @@ test('the small Settings drawer filters destinations, restores navigation and cl
5254
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(356)
5355
})
5456

55-
test('editing a display name resets cancelled drafts and updates the displayed profile on submit', async ({
57+
test('cancelled profile edits reset and saved name and picture survive Settings navigation', async ({
5658
page,
5759
}) => {
5860
await page.setViewportSize({ width: 356, height: 740 })
@@ -70,4 +72,30 @@ test('editing a display name resets cancelled drafts and updates the displayed p
7072
const button = page.getByRole('button', { name: replacement })
7173
await expect(button).toBeInViewport({ ratio: 1 })
7274
await expect(page.getByRole('img', { name: replacement })).toBeVisible()
75+
await page.getByLabel('Set your profile picture').setInputFiles({
76+
name: 'photo.svg',
77+
mimeType: 'image/svg+xml',
78+
buffer: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="17" height="13"/>'),
79+
})
80+
const picture = page.getByRole('region', { name: 'Profile' }).locator('img')
81+
await expect(picture).toHaveJSProperty('naturalWidth', 17)
82+
const open = page.getByRole('button', { name: 'Expand sidebar' })
83+
const drawer = page.getByRole('dialog', { name: 'Navigation drawer' })
84+
await open.click()
85+
await drawer.getByRole('link', { name: 'Notifications' }).click()
86+
await expect(page.getByRole('heading', { name: 'Notifications', level: 1 })).toBeVisible()
87+
await open.click()
88+
await drawer.getByRole('link', { name: 'Preferences' }).click()
89+
await expect(button).toBeInViewport({ ratio: 1 })
90+
await expect(picture).toHaveJSProperty('naturalWidth', 17)
91+
await open.click()
92+
await drawer.getByRole('button', { name: 'Back to app' }).click()
93+
await expect(page.getByRole('heading', { name: 'Good morning, International' })).toBeVisible()
94+
await open.click()
95+
await expect(drawer.getByRole('img', { name: replacement })).toHaveJSProperty('naturalWidth', 17)
96+
await drawer.getByRole('button', { name: replacement }).click()
97+
await expect(button).toBeVisible()
98+
await page.reload()
99+
await expect(page.getByRole('button', { name: 'Emir Karabeg', exact: true })).toBeVisible()
100+
await expect(picture).toHaveCount(0)
73101
})

‎apps/sim/platform/checks/shell.spec.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,21 @@ test('a keyboard search navigates to the matching chat and shell edits survive t
2727
await page.getByRole('textbox', { name: 'Chat name' }).fill('Renewal follow-up')
2828
await page.getByRole('button', { name: 'Save', exact: true }).click()
2929
await expect(page.getByRole('heading', { name: 'Renewal follow-up' })).toBeVisible()
30+
const workspaces = page.getByRole('region', { name: 'Workspaces', exact: true })
31+
await workspaces.locator('summary').click()
32+
await collection.locator('summary').click()
3033
await page.getByRole('button', { name: 'Organization menu' }).click()
3134
await page
3235
.getByRole('dialog', { name: 'Organization menu' })
3336
.getByRole('button', { name: 'Settings' })
3437
.click()
3538
await page.getByRole('button', { name: 'Back to app' }).click()
39+
await expect(page.getByRole('button', { name: 'Customize Home' })).toBeVisible()
40+
await expect(workspaces.getByRole('link', { name: 'Customer operations' })).toBeHidden()
41+
await expect(collection.getByRole('link', { name: 'Renewal follow-up' })).toBeHidden()
42+
await collection.locator('summary').click()
3643
await expect(collection.getByRole('link', { name: 'Renewal follow-up' })).toBeVisible()
44+
await expect(workspaces.getByRole('link', { name: 'Customer operations' })).toBeHidden()
3745
await collection.getByRole('link', { name: 'Renewal follow-up' }).hover()
3846
await collection.getByRole('button', { name: 'Actions for Renewal follow-up' }).click()
3947
await page.getByRole('menuitem', { name: 'Archive' }).click()
@@ -87,7 +95,12 @@ test('the inbox filters unread items and each notification can be dismissed sepa
8795
await inbox.getByRole('radio', { name: 'Unread' }).check()
8896
await expect(inbox.getByRole('button', { name: /Review the renewal brief/ })).toHaveCount(0)
8997
await inbox.getByRole('button', { name: /Reconnect Salesforce/ }).click()
98+
await page.getByRole('link', { name: 'Review the renewal brief' }).click()
99+
await expect(page.getByRole('heading', { name: 'Review the renewal brief' })).toBeVisible()
100+
await page.getByRole('link', { name: 'Home', exact: true }).click()
101+
await expect(page.getByRole('button', { name: 'Customize Home' })).toBeVisible()
90102
await open.click()
103+
await expect(inbox.getByRole('radio', { name: 'Unread' })).toBeChecked()
91104
await expect(inbox.getByRole('button', { name: /Reconnect Salesforce/ })).toHaveCount(0)
92105
await page.keyboard.press('Escape')
93106
const customize = page.getByRole('button', { name: 'Customize Home', exact: true })
@@ -99,6 +112,12 @@ test('the inbox filters unread items and each notification can be dismissed sepa
99112
await page.keyboard.press('Escape')
100113
await notices.getByRole('button', { name: '4 notifications' }).click()
101114
await notices.getByRole('button', { name: 'Dismiss Syncing knowledge' }).click()
115+
await page.getByRole('button', { name: 'Sort chats' }).click()
116+
await page.getByRole('menuitemcheckbox', { name: 'Name (A–Z)' }).click()
117+
await page.getByRole('button', { name: 'Emir Karabeg' }).click()
118+
await page.getByRole('button', { name: 'Back to app' }).click()
119+
const chats = page.getByRole('region', { name: 'Chats', exact: true })
120+
await expect(chats.getByRole('link').first()).toHaveText('Customer onboarding')
102121
await expect(notices.getByRole('button', { name: '3 notifications' })).toBeVisible()
103122
await notices
104123
.getByRole('article', { name: 'Email hidden' })

‎apps/sim/platform/design-system/components/collections.module.css‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@
5959
white-space: nowrap;
6060
color: var(--ui-ink);
6161
}
62+
.recentItem > button {
63+
align-self: stretch;
64+
cursor: pointer;
65+
}
6266
.attachmentItems {
6367
display: grid;
6468
gap: var(--spacing-8);
@@ -104,6 +108,9 @@
104108
.recentItem {
105109
min-height: var(--spacing-app-touch-target);
106110
}
111+
.recentItem > button {
112+
min-inline-size: var(--spacing-app-touch-target);
113+
}
107114
}
108115
.steps {
109116
display: grid;
@@ -138,6 +145,9 @@
138145
}
139146

140147
.workspaceHit {
148+
align-self: stretch;
149+
display: grid;
150+
align-items: center;
141151
width: 100%;
142152
cursor: pointer;
143153
text-align: start;
@@ -159,3 +169,11 @@
159169
.recentItem:hover .removeRecent > svg:last-child {
160170
display: block;
161171
}
172+
@media (hover: none) {
173+
.removeRecent > svg:first-child {
174+
display: none;
175+
}
176+
.removeRecent > svg:last-child {
177+
display: block;
178+
}
179+
}

0 commit comments

Comments
 (0)