Skip to content

Commit 17a999e

Browse files
committed
fix(chat): preserve per-chat resource panel widths
1 parent 59e1e70 commit 17a999e

21 files changed

Lines changed: 745 additions & 252 deletions

File tree

‎.github/workflows/desktop-e2e.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ on:
1111
- '.github/workflows/desktop-release.yml'
1212
- 'apps/desktop/**'
1313
- 'apps/sim/app/_shell/desktop-update-*.tsx'
14+
- 'apps/sim/app/workspace/**/home/hooks/use-mothership-resize.ts'
15+
- 'apps/sim/app/workspace/**/home/hooks/use-resource-panel.ts'
16+
- 'apps/sim/app/workspace/**/home/components/chat-panel-layout.tsx'
17+
- 'apps/sim/stores/chat-panel/**'
18+
- 'apps/sim/scripts/fixtures/chat-panel.tsx'
1419
- 'apps/sim/app/layout.tsx'
1520
- 'apps/sim/hooks/use-desktop-update-state.ts'
1621
- 'apps/sim/lib/desktop/**'
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2+
import { createServer } from 'node:http'
3+
import { tmpdir } from 'node:os'
4+
import { dirname, join } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import { _electron as electron, expect, test } from '@playwright/test'
7+
import { getErrorMessage } from '@sim/utils/errors'
8+
import { build } from 'esbuild'
9+
import postcss from 'postcss'
10+
import loadPostcssConfig from 'postcss-load-config'
11+
12+
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
13+
const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url))
14+
const FIXTURE = fileURLToPath(new URL('../../sim/scripts/fixtures/chat-panel.tsx', import.meta.url))
15+
16+
test('chat panel sizes survive navigation, chat switches, collapse, and layout constraints', async () => {
17+
const reportPath = process.env.CHAT_PANEL_REPORT_PATH ?? test.info().outputPath('chat-panel.json')
18+
const checks: {
19+
name: string
20+
status: 'passed' | 'failed'
21+
durationMs: number
22+
error?: string
23+
}[] = []
24+
const check = async (name: string, run: () => Promise<void>) => {
25+
const started = Date.now()
26+
try {
27+
await test.step(name, run)
28+
checks.push({ name, status: 'passed', durationMs: Date.now() - started })
29+
} catch (error) {
30+
checks.push({
31+
name,
32+
status: 'failed',
33+
durationMs: Date.now() - started,
34+
error: getErrorMessage(error),
35+
})
36+
throw error
37+
}
38+
}
39+
const userData = mkdtempSync(join(tmpdir(), 'sim-chat-panel-e2e-'))
40+
let app: Awaited<ReturnType<typeof electron.launch>> | undefined
41+
let passed = false
42+
let javascript = ''
43+
let stylesheet = ''
44+
const server = createServer((request, response) => {
45+
const path = new URL(request.url ?? '/', 'http://localhost').pathname
46+
if (path === '/fixture.js' || path === '/fixture.css') {
47+
response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css')
48+
response.end(path.endsWith('.js') ? javascript : stylesheet)
49+
} else if (path.startsWith('/api/')) {
50+
response.setHeader('Content-Type', 'application/json')
51+
response.end('{}')
52+
} else {
53+
response.setHeader('Content-Type', 'text/html')
54+
response.end(
55+
'<!doctype html><html class="dark"><head><link rel="stylesheet" href="/fixture.css"></head><body style="margin:0;background:var(--bg);color:var(--text-primary)"><div id="root"></div><script src="/fixture.js"></script></body></html>'
56+
)
57+
}
58+
})
59+
60+
try {
61+
await check('load the production resource panel resize hook in Electron', async () => {
62+
const config = await loadPostcssConfig({}, SIM_DIR)
63+
const cssPath = join(SIM_DIR, 'app/_styles/globals.css')
64+
const css = await postcss(config.plugins).process(
65+
`${readFileSync(cssPath, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`,
66+
{ from: cssPath }
67+
)
68+
const bundle = await build({
69+
entryPoints: [FIXTURE],
70+
bundle: true,
71+
write: false,
72+
outfile: test.info().outputPath('fixture.js'),
73+
external: ['node:async_hooks'],
74+
banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' },
75+
format: 'iife',
76+
platform: 'browser',
77+
tsconfig: join(SIM_DIR, 'tsconfig.json'),
78+
define: { 'process.env.NODE_ENV': '"development"' },
79+
})
80+
javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? ''
81+
stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}`
82+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
83+
const address = server.address()
84+
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
85+
app = await electron.launch({
86+
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
87+
cwd: DESKTOP_DIR,
88+
env: {
89+
...process.env,
90+
SIM_DESKTOP_ORIGIN: `http://127.0.0.1:${address.port}`,
91+
SIM_DESKTOP_USER_DATA: userData,
92+
},
93+
})
94+
})
95+
if (!app) throw new Error('Electron did not launch')
96+
const shell = app
97+
const page = await shell.firstWindow()
98+
const errors: string[] = []
99+
page.on('pageerror', (error) => errors.push(error.message))
100+
await shell.evaluate(({ app, BrowserWindow }) => {
101+
const window = BrowserWindow.getAllWindows()[0]
102+
window.setContentSize(1440, 800)
103+
window.webContents.setBackgroundThrottling(false)
104+
app.focus({ steal: true })
105+
window.focus()
106+
})
107+
await page.reload()
108+
expect(errors).toEqual([])
109+
const panel = page.locator('[data-mothership-panel]')
110+
const divider = page.getByRole('separator', { name: 'Resize resource view' })
111+
const width = () => panel.evaluate((element) => element.getBoundingClientRect().width)
112+
const expectWidth = async (expected: number) => {
113+
await expect.poll(async () => Math.abs((await width()) - expected)).toBeLessThan(2)
114+
}
115+
const dragTo = async (target: number) => {
116+
await divider.hover({ position: { x: 4, y: 100 } })
117+
const rect = await panel.boundingBox()
118+
if (!rect) throw new Error('Missing panel bounds')
119+
await page.mouse.down()
120+
await expect
121+
.poll(() => divider.evaluate((element) => element.hasPointerCapture(1)))
122+
.toBe(true)
123+
await page.mouse.move(rect.x + rect.width - target, rect.y + 100, { steps: 12 })
124+
await page.mouse.up()
125+
await expectWidth(target)
126+
}
127+
128+
await check('a chosen width survives a settings round trip and reload', async () => {
129+
await expectWidth(720)
130+
await dragTo(620)
131+
await page.getByRole('button', { name: 'Settings', exact: true }).click()
132+
await expect(panel).toHaveCount(0)
133+
await page.getByRole('button', { name: 'Back', exact: true }).click()
134+
await expectWidth(620)
135+
await page.reload()
136+
await expectWidth(620)
137+
})
138+
139+
await check(
140+
'workspace and organization chats restore independent widths without remounting',
141+
async () => {
142+
await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click()
143+
await expectWidth(720)
144+
await dragTo(830)
145+
await page.getByRole('button', { name: 'organization-chat-a', exact: true }).click()
146+
await expectWidth(720)
147+
await dragTo(560)
148+
await page.getByRole('button', { name: 'Settings', exact: true }).click()
149+
await page.getByRole('button', { name: 'Back', exact: true }).click()
150+
await expectWidth(560)
151+
await page.getByRole('button', { name: 'workspace-chat-a', exact: true }).click()
152+
await expectWidth(620)
153+
await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click()
154+
await expectWidth(830)
155+
}
156+
)
157+
158+
await check(
159+
'collapse preserves the expanded preference, including keyboard changes',
160+
async () => {
161+
await page.getByRole('button', { name: 'Collapse resource view' }).click()
162+
await expectWidth(0)
163+
await page.getByRole('button', { name: 'Expand resource view' }).click()
164+
await expectWidth(830)
165+
await divider.focus()
166+
await page.keyboard.press('ArrowRight')
167+
await expectWidth(798)
168+
await page.getByRole('button', { name: 'Settings', exact: true }).click()
169+
await page.getByRole('button', { name: 'Back', exact: true }).click()
170+
await expectWidth(798)
171+
}
172+
)
173+
174+
await check('container and window clamps do not overwrite the preferred width', async () => {
175+
await page.getByRole('button', { name: 'Resize container' }).click()
176+
await expectWidth(520)
177+
await page.getByRole('button', { name: 'Resize container' }).click()
178+
await expectWidth(798)
179+
await shell.evaluate(({ BrowserWindow }) =>
180+
BrowserWindow.getAllWindows()[0].setContentSize(1050, 800)
181+
)
182+
await expectWidth(570)
183+
await page.getByRole('button', { name: 'Settings', exact: true }).click()
184+
await page.getByRole('button', { name: 'Back', exact: true }).click()
185+
await expectWidth(570)
186+
await shell.evaluate(({ BrowserWindow }) =>
187+
BrowserWindow.getAllWindows()[0].setContentSize(1440, 800)
188+
)
189+
await expectWidth(798)
190+
})
191+
192+
await check('resizing writes storage only when the gesture ends', async () => {
193+
const before = await page.evaluate(() => JSON.stringify(localStorage))
194+
const rect = await panel.boundingBox()
195+
if (!rect) throw new Error('Missing panel bounds')
196+
await page.mouse.move(rect.x, rect.y + 100)
197+
await page.mouse.down()
198+
await expect
199+
.poll(() => divider.evaluate((element) => element.hasPointerCapture(1)))
200+
.toBe(true)
201+
await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 })
202+
await expectWidth(698)
203+
expect(await page.evaluate(() => JSON.stringify(localStorage))).toBe(before)
204+
await page.mouse.up()
205+
expect(await page.evaluate(() => JSON.stringify(localStorage))).not.toBe(before)
206+
})
207+
208+
await check('another account cannot inherit the current chat width', async () => {
209+
await page.getByRole('button', { name: 'Switch account' }).click()
210+
await expectWidth(720)
211+
await dragTo(580)
212+
await page.getByRole('button', { name: 'Switch account' }).click()
213+
await expectWidth(698)
214+
expect(errors).toEqual([])
215+
})
216+
passed = true
217+
} finally {
218+
mkdirSync(dirname(reportPath), { recursive: true })
219+
writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2))
220+
await app?.close()
221+
await new Promise<void>((resolve) => server.close(() => resolve()))
222+
rmSync(userData, { recursive: true, force: true })
223+
}
224+
})

‎apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import { useEffect, useState } from 'react'
44
import { cn } from '@sim/emcn'
55
import { ArrowRight } from '@sim/emcn/icons'
6-
import Link from 'next/link'
76
import { HomeSection } from '@/components/home/home-section'
7+
import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link'
88
import { OAUTH_SEARCH_READ_SCOPE, oauthScopeSatisfies } from '@/lib/auth/oauth-provider'
99
import type { ResourceScope } from '@/lib/core/resource-scope'
1010
import { organizationRoutes } from '@/lib/navigation/paths'
@@ -142,7 +142,11 @@ export function GetStarted() {
142142
{steps.map((step, i) => {
143143
const complete = completed[step.id]
144144
return (
145-
<Link key={step.id} href={hrefs[step.id]} className={cn(ROW_CLASS, i > 0 && 'border-t')}>
145+
<SettingsGuardedLink
146+
key={step.id}
147+
href={hrefs[step.id]}
148+
className={cn(ROW_CLASS, i > 0 && 'border-t')}
149+
>
146150
<StepMark complete={complete} />
147151
<span
148152
className={cn(
@@ -153,7 +157,7 @@ export function GetStarted() {
153157
{step.label}
154158
</span>
155159
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
156-
</Link>
160+
</SettingsGuardedLink>
157161
)
158162
})}
159163
</HomeSection>

‎apps/sim/app/o/[organizationId]/home/organization-home.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ function OrganizationHomeContent({
141141
!hasChat && mothershipAvailable && canBuild && (searchAccess.memberScoped || planEnabled)
142142
const liveSearch = getDeploymentShape().features.liveEnterpriseSearch === true
143143
const assistantSearchLevel = 'fast'
144-
const panel = useChatResourcePanel(chat, controller)
144+
const panel = useChatResourcePanel(chat, controller, userId)
145145
const addResource = panel.addResourceFromUser
146146
/** Restore only an explicitly selected results tab on an empty Home; closing it clears the URL. */
147147
useEffect(() => {

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/organization-secret-input.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { createContext, type ReactNode, useContext } from 'react'
4+
import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link'
45
import { ApiClientError } from '@/lib/api/client/errors'
56
import { useSession } from '@/lib/auth/auth-client'
67
import { organizationRoutes } from '@/lib/navigation/paths'
@@ -75,12 +76,12 @@ export function OrganizationSecretInputHost({
7576
{organizationContext.viewer.isAdmin ? (
7677
<>
7778
Enable Generic Secrets in{' '}
78-
<a
79+
<SettingsGuardedLink
7980
className='underline'
8081
href={organizationRoutes(organizationId).settingsSection('integrations')}
8182
>
8283
organization Integrations settings
83-
</a>
84+
</SettingsGuardedLink>
8485
, then return here to enter the keys.
8586
</>
8687
) : (

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
parseSearchConnectionBody,
5454
searchConnectionTargetSchema,
5555
} from '@/lib/knowledge/search/connection-target'
56+
import { rememberSettingsReturnUrl } from '@/lib/navigation/settings-return'
5657
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
5758
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
5859
import { organizationSecretNameSchema } from '@/lib/organization-secrets/validation'
@@ -3488,6 +3489,7 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
34883489
{canManageBilling ? (
34893490
<ChipLink
34903491
href={href}
3492+
onNavigate={() => rememberSettingsReturnUrl(href)}
34913493
variant='border'
34923494
rightIcon={hosted ? ArrowRight : SquareArrowUpRight}
34933495
target={hosted ? undefined : '_blank'}

‎apps/sim/app/workspace/[workspaceId]/home/home.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ function HomeContent({ chatId, userName, userId }: HomeProps) {
178178
dispatchingHeadId,
179179
getCurrentRequestId,
180180
} = chat
181-
const panel = useChatResourcePanel(chat, controller)
181+
const panel = useChatResourcePanel(chat, controller, userId)
182182
const {
183183
isResourceCollapsed,
184184
skipResourceTransition,

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-ca
126126
import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query'
127127
import { workflowKeys } from '@/hooks/queries/workflows'
128128
import { snapAllSmoothText } from '@/hooks/use-smooth-text'
129+
import { useChatPanelStore } from '@/stores/chat-panel/store'
129130
import { useMothershipEffortStore } from '@/stores/mothership-effort/store'
130131
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
131132
import type {
@@ -1183,6 +1184,9 @@ export function useChat(
11831184
: pendingChatKeyRef.current
11841185
chatIdRef.current = chatId
11851186
const resolvedDesktopScopeId = desktopChatScopeId(scopeKey, chatId)
1187+
if (wasPending) {
1188+
useChatPanelStore.getState().migrate(pendingDesktopScopeId, resolvedDesktopScopeId)
1189+
}
11861190
const activeActivityTracker = resourceActivityTrackerRef.current
11871191
if (activeActivityTracker?.generation === streamGenRef.current) {
11881192
if (wasPending) {
@@ -1753,6 +1757,7 @@ export function useChat(
17531757
return
17541758
}
17551759

1760+
useChatPanelStore.getState().migrate(previousDesktopScopeId, resolvedChatId)
17561761
await migrateDesktopChatScopes(previousDesktopScopeId, resolvedChatId)
17571762
if (pendingChatKey) {
17581763
useMothershipQueueStore.getState().migrate(pendingChatKey, resolvedChatId)

0 commit comments

Comments
 (0)