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
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client'

import { useId, useSyncExternalStore } from 'react'
import { useId } from 'react'
import { cn } from '@sim/emcn'
import { Moon, Sun } from '@sim/emcn/icons'
import { useTheme } from 'next-themes'
import { useHydrated } from '@/hooks/use-hydrated'

/**
* The two themes on offer, in reading order. `system` is deliberately absent:
Expand Down Expand Up @@ -33,21 +34,6 @@ const SEGMENT_TONE: Record<ThemeOption, string> = {
dark: 'text-[var(--text-muted)] hover-hover:text-[var(--text-primary)] dark:bg-[var(--surface-active)] dark:text-[var(--text-primary)]',
}

const subscribeToNothing = () => () => {}

/**
* `true` once hydrated, `false` in server HTML and during hydration - the
* store-backed form React reconciles without a mismatch, unlike an effect that
* flips state after mount.
*/
function useHydrated(): boolean {
return useSyncExternalStore(
subscribeToNothing,
() => true,
() => false
)
}

/**
* Footer light/dark switch: a hairline pill holding a sun and a moon, with the
* current theme's segment filled. Picking a segment writes the visitor's
Expand Down
9 changes: 3 additions & 6 deletions apps/sim/app/o/[organizationId]/home/organization-home.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from '@sim/emcn'
import { useQueryClient } from '@tanstack/react-query'
import Link from 'next/link'
Expand Down Expand Up @@ -48,6 +48,7 @@ import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { mentionifyIntegrations } from '@/blocks/integration-matcher'
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
import { getWorkspaceFilesQueryOptions } from '@/hooks/queries/workspace-files'
import { useHydrated } from '@/hooks/use-hydrated'
import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/store'
import type { ChatContext } from '@/stores/panel'
Expand All @@ -58,15 +59,11 @@ interface OrganizationHomeProps {
requestMode?: ChatRequestMode
}

const subscribeToClient = () => () => {}
const clientSnapshot = () => true
const serverSnapshot = () => false

/** Home chooses the conversation mode before its first message. */
export function OrganizationHome(props: OrganizationHomeProps) {
const { organization, searchAccess, canBuild, mothershipAvailable } = useOrganizationContext()
const { data: session } = useSession()
const isClient = useSyncExternalStore(subscribeToClient, clientSnapshot, serverSnapshot)
const isClient = useHydrated()
const isRestoredChatEntry = useRestoredChatEntry({ chatId: props.chatId })
if (!mothershipAvailable || (!canBuild && !searchAccess.memberScoped)) return null
/** Preferences are browser-persisted and keyed by user; never paint a guessed mode first. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
*/
import { act, type ReactNode } from 'react'
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
import { createRoot, type Root } from 'react-dom/client'
import { createRoot, hydrateRoot, type Root } from 'react-dom/client'
import { renderToString } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'

vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) }))
Expand Down Expand Up @@ -458,3 +459,70 @@ it('inserts a canonical built-in skill globally without inheriting a workspace',
unmount()
}
})

it('keeps text typed into the server-rendered textarea before hydration', () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
let latest: ReturnType<typeof usePromptEditor> | undefined
function Editor({ revision }: { revision: number }) {
const editor = usePromptEditor({ workspaceId: 'ws-1' })
latest = editor
return (
<textarea
ref={editor.textareaRef}
value={editor.value}
onChange={editor.handleInputChange}
data-revision={revision}
/>
)
}
const container = document.createElement('div')
container.innerHTML = renderToString(<Editor revision={0} />)
document.body.appendChild(container)
const textarea = container.querySelector('textarea')
if (!textarea) throw new Error('The server must render the textarea')
textarea.value = 'Typed while the page loaded'
let root: Root | undefined
try {
act(() => {
root = hydrateRoot(container, <Editor revision={0} />)
})
act(() => root?.render(<Editor revision={1} />))
expect(latest?.getValue()).toBe('Typed while the page loaded')
expect(textarea.value).toBe('Typed while the page loaded')
} finally {
act(() => root?.unmount())
container.remove()
}
})

it('re-chipifies a seeded draft once skills load', () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const skill = {
id: 'review',
workspaceId: 'ws-1',
userId: null,
name: 'Review',
description: '',
content: '',
canEdit: false,
createdAt: '',
updatedAt: '',
}
let latest: ReturnType<typeof usePromptEditor> | undefined
function Editor({ skills }: { skills: (typeof skill)[] }) {
latest = usePromptEditor({ workspaceId: 'ws-1', availableSkills: skills })
return null
}
const container = document.createElement('div')
const root = createRoot(container)
try {
act(() => root.render(<Editor skills={[]} />))
act(() => latest?.setValue('/Review the draft', { seed: true }))
act(() => root.render(<Editor skills={[skill]} />))
expect(latest?.getActiveContexts()).toEqual([
{ kind: 'skill', skillId: 'review', label: 'Review' },
])
} finally {
act(() => root.unmount())
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,12 @@ export function usePromptEditor({
* Programmatically replaces the editor text. Chipifies by default so any
* seeded prose (template, transcript, queued message) registers its
* integration / skill chips; pass `{ chipify: false }` for verbatim text.
* `{ seed: true }` treats the text like `initialValue`, re-chipified once
* the skill list loads while it stays untouched.
*/
const setValue = useCallback((text: string, options?: { chipify?: boolean }) => {
const setValue = useCallback((text: string, options?: { chipify?: boolean; seed?: boolean }) => {
const next = options?.chipify === false ? text : applyAutoMentionsRef.current(text)
if (options?.seed) seedRef.current = next
atInsertPosRef.current = null
pendingCursorRef.current = null
mentionRangeRef.current = null
Expand Down Expand Up @@ -1056,6 +1059,15 @@ export function usePromptEditor({
setValueState(textarea.value)
}, [])

// Keystrokes typed into the server-rendered textarea before hydration never
// reach React. Compared with the mounted value: `valueRef` leads the DOM
// while an edit made during mount is still uncommitted.
const mountedValueRef = useRef(initialValue)
useLayoutEffect(() => {
const textarea = textareaRef.current
if (textarea && textarea.value !== mountedValueRef.current) adoptDomValue(textarea)
}, [textareaRef, adoptDomValue])

// Selection one change ago, used to infer which edge of a range moved. Kept
// current by the `selectionchange` listener below — which fires on EVERY
// caret/selection change (typing, arrows, clicks, programmatic), unlike
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]
import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
import { mentionifyIntegrations } from '@/blocks/integration-matcher'
import { useChatInputFocus } from '@/hooks/use-chat-input-focus'
import { useHydrated } from '@/hooks/use-hydrated'
import { isMobileViewport } from '@/hooks/use-is-mobile'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useVoiceInput } from '@/hooks/use-voice-input'
Expand Down Expand Up @@ -104,9 +105,11 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
const { workspaceId } = useParams<{ workspaceId: string }>()
const { navigateToSettings } = useSettingsNavigation()
const { userId, onContextAdd, onContextRemove } = useChatSurface()
// Server HTML cannot see the stored draft; hydration renders without it and the mount restore applies it.
const canReadStoredDraft = useHydrated()
const [initialValue] = useState(() => {
if (defaultValue) return defaultValue
if (!draftScopeKey) return ''
if (!canReadStoredDraft || !draftScopeKey) return ''
const text = useMothershipDraftsStore.getState().drafts[draftScopeKey]?.text
return typeof text === 'string' ? text : ''
})
Expand Down Expand Up @@ -200,7 +203,10 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
}
if (restoredContexts) editor.setContexts(restoredContexts)
if (restoredFiles) files.restoreAttachedFiles(restoredFiles)
if (caretText !== null) {
// Text typed before hydration wins over the saved draft, caret included.
const typedBeforeHydration = initialValue === '' && editor.getValue() !== ''
if (caretText !== null && !typedBeforeHydration) {
if (initialValue === '') editor.setValue(caretText, { seed: true })
const textarea = textareaRef.current
if (textarea) {
if (!isMobileViewport()) textarea.focus()
Expand Down
13 changes: 13 additions & 0 deletions apps/sim/hooks/use-hydrated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useSyncExternalStore } from 'react'

const subscribeToNothing = () => () => {}
const onClient = () => true
const onServer = () => false

/**
* `true` once hydrated, `false` in server HTML and during hydration: the store-backed form React
* reconciles without a mismatch, unlike an effect that flips state after mount.
*/
export function useHydrated(): boolean {
return useSyncExternalStore(subscribeToNothing, onClient, onServer)
}
27 changes: 26 additions & 1 deletion apps/sim/scripts/test-mobile-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,27 @@ async function exerciseViewport(
}

if (viewport.name === 'desktop') {
await check(`${prefix}/a saved draft returns after a reload`, page, async () => {
const draft = 'A draft saved before reloading'
const storedDrafts = () =>
page.evaluate(() => localStorage.getItem('mothership-drafts:v1') ?? '')
await visit(page, 'home')
const composer = page.getByRole('textbox', { name: 'Message', exact: true })
await composer.fill(draft)
await expect.poll(storedDrafts).toContain(draft)
const pageErrorCount = pageErrors.length
const nativeErrorCount = nativeErrors.length
await visit(page, 'home')
await expect(composer).toHaveValue(draft)
assert.deepEqual(
[...pageErrors.slice(pageErrorCount), ...nativeErrors.slice(nativeErrorCount)],
[],
'Restoring a saved draft must hydrate cleanly'
)
await composer.fill('')
await expect.poll(storedDrafts).not.toContain(draft)
})

for (const width of [390, viewport.width]) {
await check(
`${prefix}/${width}px draft scroll boundaries preserve the surrounding page`,
Expand Down Expand Up @@ -591,8 +612,12 @@ async function exerciseViewport(
try {
await page.setViewportSize({ width, height: 520 })
await composer.fill('A draft line\n'.repeat(30))
await expect
.poll(async () => (await readScroll().catch(() => null))?.max ?? 0, {
message: 'The draft must overflow its editor',
})
.toBeGreaterThan(100)
const editor = await readScroll()
assert(editor.max > 100, 'The draft must overflow its editor')
await page.mouse.move(editor.x, editor.y)
await page.mouse.wheel(0, 10_000)
await expect
Expand Down
Loading