Skip to content

Commit ade7544

Browse files
committed
fix(search): tighten draft recovery and shared sidebar behavior
1 parent 03cd8f6 commit ade7544

19 files changed

Lines changed: 280 additions & 88 deletions

File tree

‎apps/sim/app/o/[organizationId]/components/search-input-bar.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export function SearchInputBar({
8181
<div
8282
ref={scrollerRef}
8383
className={cn(
84-
'relative min-h-[46px] w-full rounded-[23px] border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]',
84+
'relative min-h-[46px] w-full rounded-full border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]',
8585
(hasAttachments || expanded) && 'rounded-2xl',
8686
floating && 'shadow-ambient'
8787
)}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import { getWorkspaceHostContextContract } from '@/lib/api/contracts/workspaces'
1111
import { useSession } from '@/lib/auth/auth-client'
1212
import { getDeploymentShape } from '@/lib/core/config/deployment-shape'
1313
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
14-
import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview'
14+
import {
15+
getMothershipAttachmentPreviewUrl,
16+
getMothershipAttachmentUrl,
17+
} from '@/lib/mothership/chat/attachment-preview'
1518
import { createSearchResource } from '@/lib/mothership/resources/search'
1619
import { Composer } from '@/app/o/[organizationId]/home/components/composer'
1720
import { GetStarted } from '@/app/o/[organizationId]/home/components/get-started'
@@ -350,7 +353,10 @@ function OrganizationHomeContent({
350353
name: file.filename,
351354
type: file.media_type,
352355
size: file.size,
353-
path: file.path || getMothershipAttachmentPreviewUrl(file) || '',
356+
path:
357+
file.path ||
358+
getMothershipAttachmentPreviewUrl(file) ||
359+
getMothershipAttachmentUrl(file),
354360
previewUrl: getMothershipAttachmentPreviewUrl(file),
355361
uploading: false,
356362
}))

‎apps/sim/app/o/[organizationId]/search/search.test.tsx‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,52 @@ describe('organization Search query navigation', () => {
212212
expect(mocks.urlUpdate).not.toHaveBeenCalled()
213213
})
214214

215+
it('keeps edits for separate committed queries across storage rehydration', async () => {
216+
await render('?q=Orion')
217+
await editDraft('Orion follow-up')
218+
await render('?q=Vega')
219+
await editDraft('Vega follow-up')
220+
await act(async () => root.unmount())
221+
const saved = localStorage.getItem('mothership-drafts:v1')!
222+
useMothershipDraftsStore.setState({ drafts: {} })
223+
localStorage.setItem('mothership-drafts:v1', saved)
224+
await useMothershipDraftsStore.persist.rehydrate()
225+
root = createRoot(container)
226+
227+
await render('?q=Orion')
228+
expect(searchInput().value).toBe('Orion follow-up')
229+
await render('?q=Vega')
230+
expect(searchInput().value).toBe('Vega follow-up')
231+
await render('')
232+
expect(searchInput().value).toBe('Vega follow-up')
233+
})
234+
235+
it('submits one query without clearing another query’s latest draft', async () => {
236+
await render('?q=Orion')
237+
await editDraft('Orion follow-up')
238+
await render('?q=Vega')
239+
await editDraft('Vega follow-up')
240+
await render('?q=Orion')
241+
await act(async () =>
242+
searchInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
243+
)
244+
expectVisibleQuery('Orion follow-up')
245+
await render('')
246+
expect(searchInput().value).toBe('Vega follow-up')
247+
await editDraft('Vega revised')
248+
await render('?q=Vega')
249+
expect(searchInput().value).toBe('Vega revised')
250+
await render('')
251+
await act(async () =>
252+
searchInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
253+
)
254+
expectVisibleQuery('Vega revised')
255+
await render('?q=Vega')
256+
expectVisibleQuery('Vega')
257+
await render('')
258+
expect(searchInput().value).toBe('')
259+
})
260+
215261
it.each(['Enter', 'button'] as const)(
216262
'keeps the draft out of Search until %s commits it and restores input focus afterward',
217263
async (submit) => {

‎apps/sim/app/o/[organizationId]/search/search.tsx‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,24 @@ interface SearchFieldProps {
3434
function SearchField({ userId, initialValue, onSubmit }: SearchFieldProps) {
3535
const inputRef = useRef<HTMLTextAreaElement>(null)
3636
const { organization } = useOrganizationContext()
37-
const draftKey = `${userId}:organization:${organization.id}:search`
37+
const latestDraftKey = `${userId}:organization:${organization.id}:search`
38+
const latestDraft = useMothershipDraftsStore((state) => state.drafts[latestDraftKey])
39+
const ownerQuery = initialValue || latestDraft?.searchQuery || ''
40+
const draftKey = `${latestDraftKey}:query:${encodeURIComponent(ownerQuery)}`
3841
const draft = useMothershipDraftsStore((state) => state.drafts[draftKey])
3942
const value =
40-
draft && (!initialValue || draft.searchQuery === initialValue) ? draft.text : initialValue
43+
draft?.text ?? (latestDraft?.searchQuery === ownerQuery ? latestDraft.text : initialValue)
4144
const setValue = (text: string) => {
42-
useMothershipDraftsStore.getState().setDraft(draftKey, { text, searchQuery: initialValue })
45+
const { setDraft } = useMothershipDraftsStore.getState()
46+
const payload = { text, searchQuery: ownerQuery }
47+
setDraft(draftKey, payload)
48+
setDraft(latestDraftKey, payload)
4349
}
4450
const submit = () => {
4551
if (!value.trim()) return
46-
useMothershipDraftsStore.getState().clearDraft(draftKey)
52+
const { clearDraft } = useMothershipDraftsStore.getState()
53+
clearDraft(draftKey)
54+
if (latestDraft?.searchQuery === ownerQuery) clearDraft(latestDraftKey)
4755
onSubmit(value)
4856
}
4957
const voice = useVoiceInput({

‎apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx‎

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ function stubRect(
4545
}
4646

4747
interface Harness {
48-
state: () => { isPeekActive: boolean; isPeekOpen: boolean }
48+
state: () => { isPeekActive: boolean }
4949
card: () => HTMLElement
5050
trigger: () => HTMLElement
5151
triggerEnter: () => void
@@ -66,13 +66,13 @@ function renderPeek(initialEnabled: boolean): Harness {
6666
document.body.appendChild(container)
6767
const root: Root = createRoot(container)
6868

69-
let latest = { isPeekActive: false, isPeekOpen: false }
69+
let latest = { isPeekActive: false }
7070
let onTriggerEnter = () => {}
7171
let onTriggerLeave = () => {}
7272

7373
function Probe({ enabled, dismissed }: { enabled: boolean; dismissed: boolean }) {
7474
const peek = useSidebarPeek(enabled, dismissed)
75-
latest = { isPeekActive: peek.isPeekActive, isPeekOpen: peek.isPeekOpen }
75+
latest = { isPeekActive: peek.isPeekActive }
7676
onTriggerEnter = peek.onTriggerEnter
7777
onTriggerLeave = peek.onTriggerLeave
7878
return (
@@ -180,7 +180,6 @@ describe('useSidebarPeek', () => {
180180
vi.advanceTimersByTime(1)
181181
})
182182
expect(active.state().isPeekActive).toBe(true)
183-
expect(active.state().isPeekOpen).toBe(true)
184183
})
185184

186185
it('does not open when the pointer leaves before the dwell elapses', () => {
@@ -216,7 +215,7 @@ describe('useSidebarPeek', () => {
216215
vi.advanceTimersByTime(CLOSE_DELAY_MS * 3)
217216
})
218217

219-
expect(active.state().isPeekOpen).toBe(true)
218+
expect(active.state().isPeekActive).toBe(true)
220219
})
221220

222221
it('does not force layout while the pointer moves across sidebar content', () => {
@@ -232,7 +231,7 @@ describe('useSidebarPeek', () => {
232231

233232
expect(cardMeasure).not.toHaveBeenCalled()
234233
expect(triggerMeasure).not.toHaveBeenCalled()
235-
expect(active.state().isPeekOpen).toBe(true)
234+
expect(active.state().isPeekActive).toBe(true)
236235
})
237236

238237
it('stays open while the pointer is still over the toggle that opened it', () => {
@@ -244,7 +243,7 @@ describe('useSidebarPeek', () => {
244243
vi.advanceTimersByTime(CLOSE_DELAY_MS * 3)
245244
})
246245

247-
expect(active.state().isPeekOpen).toBe(true)
246+
expect(active.state().isPeekActive).toBe(true)
248247
})
249248

250249
it('stays open while the pointer is over a portalled popper', async () => {
@@ -258,20 +257,19 @@ describe('useSidebarPeek', () => {
258257
vi.advanceTimersByTime(CLOSE_DELAY_MS * 3)
259258
})
260259

261-
expect(active.state().isPeekOpen).toBe(true)
260+
expect(active.state().isPeekActive).toBe(true)
262261
})
263262

264263
it('retracts after the grace period once the pointer moves to content', () => {
265264
active = renderPeek(true)
266265
openPeek(active)
267266

268267
movePointerTo(POINT.onContent)
269-
expect(active.state().isPeekOpen).toBe(true)
268+
expect(active.state().isPeekActive).toBe(true)
270269

271270
act(() => {
272271
vi.advanceTimersByTime(CLOSE_DELAY_MS)
273272
})
274-
expect(active.state().isPeekOpen).toBe(false)
275273
expect(active.state().isPeekActive).toBe(false)
276274
})
277275

@@ -284,7 +282,7 @@ describe('useSidebarPeek', () => {
284282
vi.advanceTimersByTime(CLOSE_DELAY_MS * 2)
285283
})
286284

287-
expect(active.state().isPeekOpen).toBe(true)
285+
expect(active.state().isPeekActive).toBe(true)
288286
})
289287

290288
it('cancels a pending retraction when the pointer returns', () => {
@@ -300,7 +298,7 @@ describe('useSidebarPeek', () => {
300298
vi.advanceTimersByTime(CLOSE_DELAY_MS * 2)
301299
})
302300

303-
expect(active.state().isPeekOpen).toBe(true)
301+
expect(active.state().isPeekActive).toBe(true)
304302
})
305303

306304
it('does not open on hover while a modal is already open', () => {
@@ -337,9 +335,9 @@ describe('useSidebarPeek', () => {
337335
expect(active.state().isPeekActive).toBe(false)
338336

339337
act(() => active?.triggerEnter())
340-
expect(active.state().isPeekOpen).toBe(false)
338+
expect(active.state().isPeekActive).toBe(false)
341339
act(() => vi.advanceTimersByTime(OPEN_DELAY_MS))
342-
expect(active.state().isPeekOpen).toBe(true)
340+
expect(active.state().isPeekActive).toBe(true)
343341
})
344342

345343
it('retracts when a modal opens, even with the pointer inside', () => {
@@ -349,7 +347,7 @@ describe('useSidebarPeek', () => {
349347

350348
active.setDismissed(true)
351349

352-
expect(active.state().isPeekOpen).toBe(false)
350+
expect(active.state().isPeekActive).toBe(false)
353351
})
354352

355353
it('keeps the peek open while the pointer is over a non-modal popper', async () => {
@@ -362,7 +360,7 @@ describe('useSidebarPeek', () => {
362360
vi.advanceTimersByTime(CLOSE_DELAY_MS * 2)
363361
})
364362

365-
expect(active.state().isPeekOpen).toBe(true)
363+
expect(active.state().isPeekActive).toBe(true)
366364
})
367365

368366
/**
@@ -385,7 +383,7 @@ describe('useSidebarPeek', () => {
385383
vi.advanceTimersByTime(CLOSE_DELAY_MS)
386384
})
387385

388-
expect(active.state().isPeekOpen).toBe(false)
386+
expect(active.state().isPeekActive).toBe(false)
389387
})
390388

391389
it('leaves Escape to an open popper rather than retracting', async () => {
@@ -402,7 +400,7 @@ describe('useSidebarPeek', () => {
402400
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
403401
})
404402

405-
expect(active.state().isPeekOpen).toBe(true)
403+
expect(active.state().isPeekActive).toBe(true)
406404
})
407405

408406
it('retracts on Escape when a popper is only animating closed', async () => {
@@ -417,7 +415,7 @@ describe('useSidebarPeek', () => {
417415
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
418416
})
419417

420-
expect(active.state().isPeekOpen).toBe(false)
418+
expect(active.state().isPeekActive).toBe(false)
421419
})
422420

423421
it('retracts on Escape', () => {
@@ -428,7 +426,7 @@ describe('useSidebarPeek', () => {
428426
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
429427
})
430428

431-
expect(active.state().isPeekOpen).toBe(false)
429+
expect(active.state().isPeekActive).toBe(false)
432430
})
433431

434432
it('drops the peek immediately when it stops being enabled', () => {
@@ -437,7 +435,6 @@ describe('useSidebarPeek', () => {
437435

438436
active.setEnabled(false)
439437

440-
expect(active.state().isPeekOpen).toBe(false)
441438
expect(active.state().isPeekActive).toBe(false)
442439
})
443440

@@ -454,6 +451,5 @@ describe('useSidebarPeek', () => {
454451
})
455452

456453
expect(active.state().isPeekActive).toBe(true)
457-
expect(active.state().isPeekOpen).toBe(true)
458454
})
459455
})

‎apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,6 @@ function containsPoint(element: Element | null, x: number, y: number, pad: numbe
6868
export interface SidebarPeekResult {
6969
/** Card is mounted as a floating overlay — drives positioning, chrome, and the expanded width. */
7070
isPeekActive: boolean
71-
/** Whether the floating card is visible. */
72-
isPeekOpen: boolean
7371
/** Attach to the floating card so the pointer hit-test can recognise it. */
7472
cardRef: React.RefObject<HTMLDivElement | null>
7573
/**
@@ -212,7 +210,6 @@ export function useSidebarPeek(enabled: boolean, dismissed = false): SidebarPeek
212210

213211
return {
214212
isPeekActive: isPeekVisible,
215-
isPeekOpen: isPeekVisible,
216213
cardRef,
217214
triggerRef,
218215
onTriggerEnter,

‎apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx‎

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { getDesktopBridge } from '@/lib/desktop'
99
import { SidebarChromeProvider } from '@/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context'
1010
import { useSidebarPeek } from '@/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek'
1111
import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip'
12+
import { SIDEBAR_NO_MOTION_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
13+
import { useSidebarWidth } from '@/hooks/use-sidebar-width'
1214
import { useFullscreenOriginStore } from '@/stores/fullscreen-origin'
1315
import { useSearchModalStore } from '@/stores/modals/search/store'
1416
import { useSidebarStore } from '@/stores/sidebar/store'
@@ -33,12 +35,10 @@ const FULLSCREEN_SUFFIXES = ['/upgrade'] as const
3335
* still bounds itself; see the `[data-peek]` rule in `globals.css`.
3436
*
3537
* `w-auto` shrink-wraps the inner shell, which `[data-peek]` has already put at the
36-
* expanded width. It must not be a length: `width` cannot interpolate to or from
37-
* `auto`, so entering and leaving the peek snap instead of animating — otherwise the
38-
* card widens as it appears and leaves a shrinking ghost on retract.
38+
* expanded width.
3939
*/
4040
const PEEK_CARD_CHROME =
41-
'absolute top-[var(--desktop-title-bar-height)] left-2 z-[var(--z-modal)] flex max-h-[calc(100%-var(--desktop-title-bar-height)-8px)] w-auto flex-col origin-top-left rounded-lg border border-[var(--border)]'
41+
'absolute top-[var(--desktop-title-bar-height)] left-2 z-[var(--z-modal)] flex max-h-[calc(100%-var(--desktop-title-bar-height)-8px)] w-auto flex-col rounded-lg border border-[var(--border)]'
4242

4343
/**
4444
* The divider between the rail and the content pane, dropped when there is no rail
@@ -204,10 +204,7 @@ export function WorkspaceChrome({
204204
isSearchModalOpen
205205
)
206206

207-
// Hydrate the persisted width before paint (collapse comes from the cookie/prop).
208-
useLayoutEffect(() => {
209-
void useSidebarStore.persist.rehydrate()
210-
}, [])
207+
useSidebarWidth()
211208

212209
// Remember the last non-fullscreen page so a fullscreen route's Back control
213210
// can return there, deterministically and for any trigger.
@@ -277,24 +274,6 @@ export function WorkspaceChrome({
277274
}
278275
}, [])
279276

280-
// Re-clamp the width when the window shrinks below what the persisted width
281-
// allows, so the sidebar can never grow wider than the viewport permits.
282-
useEffect(() => {
283-
let rafId: number | null = null
284-
const onResize = () => {
285-
if (rafId !== null) return
286-
rafId = requestAnimationFrame(() => {
287-
rafId = null
288-
syncSidebarWidth()
289-
})
290-
}
291-
window.addEventListener('resize', onResize)
292-
return () => {
293-
if (rafId !== null) cancelAnimationFrame(rafId)
294-
window.removeEventListener('resize', onResize)
295-
}
296-
}, [syncSidebarWidth])
297-
298277
return (
299278
<div
300279
className='desktop-workspace-window-frame relative flex min-h-0 flex-1'
@@ -310,7 +289,8 @@ export function WorkspaceChrome({
310289
<div
311290
ref={cardRef}
312291
className={cn(
313-
'sidebar-shell-outer shrink-0 overflow-hidden [&_*]:animate-none! [&_*]:transition-none!',
292+
'sidebar-shell-outer shrink-0 overflow-hidden',
293+
SIDEBAR_NO_MOTION_CLASS,
314294
isPeekActive ? PEEK_CARD_CHROME : isFullscreen ? 'w-0' : 'w-[var(--sidebar-width)]'
315295
)}
316296
data-collapsed={isCollapsed || undefined}

0 commit comments

Comments
 (0)