Skip to content

Commit dd2b2b1

Browse files
committed
improvement(mobile): refine responsive chat and workspace layouts
1 parent e89bc65 commit dd2b2b1

123 files changed

Lines changed: 2772 additions & 918 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/skills/emcn-design-review/SKILL.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ Use `ChipConfirmModal` (title "Delete/Remove {ItemType}", `confirm={{ label, onC
6262

6363
Default: `size-[14px]`. Color: `text-[var(--text-icon)]`. Scale: 14px > 16px > 12px > 20px. Use the `size-*` shorthand — flag `h-[Npx] w-[Npx]` and `h-N w-N` pairs as refactor targets.
6464

65+
## Mobile
66+
67+
Check at 320px and 390px with touch, then desktop and fullscreen. Use `Chip`/`ChipLink`'s `mobileIconOnly` for familiar mobile toolbar actions, preserving accessible names; keep labels for ambiguous choices. Keep navigation, primary actions, and dismissal reachable without hover or dragging; aim for 44px touch targets with compact visible icons/button faces and 16px editable text. Match behavior to the actual container or viewport breakpoint. Contain horizontal scrolling to intentional tables/code, fit overlays to the dynamic viewport, and keep the composer/actions reachable with the keyboard open. Reuse EMCN tokens and brief motion with reduced-motion support; preserve desktop geometry.
68+
6569
## Anti-patterns to flag
6670

6771
- Raw `<button>`/`<input>`, or legacy `Input`/`Textarea`/`Modal`, instead of the canonical chip components (`ChipInput`/`ChipTextarea`/`ChipModal`)

‎.claude/rules/sim-testing.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ contracts, and demonstrated regressions.
4747
(`sim_test`); `TEST_REDIS_URL` must be loopback. `packages/db/testing/test-infrastructure.ts` owns
4848
those checks. Isolate with a unique schema or generated IDs, and clean up in `afterAll`.
4949
- Integration files run one at a time against one shared database. A new `*.integration.ts` is
50-
picked up by CI with no workflow change, and the run writes `test-results/integration.json`, which
51-
CI uploads. Never add a passing suite to the quarantine list in `apps/sim/vitest.config.ts`.
50+
picked up by CI with no workflow change, and the run writes the JSON report configured in
51+
`vitest.shared.ts`, which CI uploads. Never add a passing suite to the quarantine list in
52+
`apps/sim/vitest.config.ts`.
5253
- `bun run test:integration` starts disposable Postgres and Redis containers, provisions the schema,
5354
and runs both workspaces; pass filenames to narrow the `apps/sim` run.
5455

‎.cursor/rules/sim-testing.mdc‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ contracts, and demonstrated regressions.
4545
(`sim_test`); `TEST_REDIS_URL` must be loopback. `packages/db/testing/test-infrastructure.ts` owns
4646
those checks. Isolate with a unique schema or generated IDs, and clean up in `afterAll`.
4747
- Integration files run one at a time against one shared database. A new `*.integration.ts` is
48-
picked up by CI with no workflow change, and the run writes `test-results/integration.json`, which
49-
CI uploads. Never add a passing suite to the quarantine list in `apps/sim/vitest.config.ts`.
48+
picked up by CI with no workflow change, and the run writes the JSON report configured in
49+
`vitest.shared.ts`, which CI uploads. Never add a passing suite to the quarantine list in
50+
`apps/sim/vitest.config.ts`.
5051
- `bun run test:integration` starts disposable Postgres and Redis containers, provisions the schema,
5152
and runs both workspaces; pass filenames to narrow the `apps/sim` run.
5253

‎.github/scripts/http-e2e.sh‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env bash
22
# Runs one end-to-end suite group over real HTTP, each against its own `next dev` app.
33
#
4-
# Usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox> (run from apps/sim)
4+
# Usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|mobile> (run from apps/sim)
55
#
66
# The job provides DATABASE_URL, BETTER_AUTH_SECRET and ENCRYPTION_KEY; each group sets the rest of
77
# its app's environment here. Reports and server logs land in $RUNNER_TEMP/e2e.
@@ -17,7 +17,7 @@
1717
# telemetry flush runs detached and still writes .next/dev).
1818
set -euo pipefail
1919

20-
group=${1:?usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox>}
20+
group=${1:?usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|mobile>}
2121
report_dir="$RUNNER_TEMP/e2e"
2222
ready_timeout_seconds=300
2323
mkdir -p "$report_dir"
@@ -156,6 +156,19 @@ case "$group" in
156156
bun run test:desktop-inbox:e2e
157157
;;
158158

159+
mobile)
160+
export NEXT_PUBLIC_FORCE_HOSTED=false
161+
export INTERNAL_API_SECRET=mobile-http-ci-local-secret-at-least-32-characters
162+
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--max-old-space-size=12288"
163+
bunx --no-install playwright install --with-deps chromium webkit
164+
start_app mobile 3024 'mobile browser' record-http-status
165+
MOBILE_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
166+
MOBILE_E2E_DATABASE_URL="$DATABASE_URL" \
167+
MOBILE_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
168+
MOBILE_E2E_REPORT_PATH="$report_dir/mobile-e2e-report.json" \
169+
bun run test:mobile:e2e
170+
;;
171+
159172
*)
160173
echo "::error::Unknown end-to-end group: $group" >&2
161174
exit 2

‎.github/workflows/checks.yml‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,12 @@ jobs:
138138
# The suites exercise HTTP behavior rather than a provisioning path, so they run once, against
139139
# the production (migrate) path. Each group's app environment lives in http-e2e.sh.
140140
#
141-
# SCIM runs two suites against a hosted app and is the longest group, so it keeps the 8 vCPU
142-
# runner; the others boot a smaller self-hosted app and fit on 4.
141+
# SCIM's hosted suites and the mobile browser matrix use 8 vCPU; the other
142+
# self-hosted suites fit on 4.
143143
e2e:
144144
name: e2e (${{ matrix.group }})
145145
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.runner || 'ubuntu-latest' }}
146-
timeout-minutes: 20
146+
timeout-minutes: ${{ matrix.group == 'mobile' && 40 || 20 }}
147147
strategy:
148148
fail-fast: false
149149
matrix:
@@ -156,6 +156,8 @@ jobs:
156156
runner: blacksmith-4vcpu-ubuntu-2404
157157
- group: desktop-inbox
158158
runner: blacksmith-4vcpu-ubuntu-2404
159+
- group: mobile
160+
runner: blacksmith-8vcpu-ubuntu-2404
159161
services:
160162
postgres:
161163
image: pgvector/pgvector:pg17

‎apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx‎

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

3-
import { type RefObject, useCallback, useMemo, useRef, useState } from 'react'
3+
import { type ReactNode, type RefObject, useCallback, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { generateId } from '@sim/utils/id'
66
import {
@@ -24,6 +24,7 @@ import { useChatStreaming } from '@/app/(interfaces)/chat/hooks'
2424
import SSOAuth from '@/ee/sso/components/sso-auth'
2525
import { useDeployedChatConfig } from '@/hooks/queries/chats'
2626
import { useGitHubStars } from '@/hooks/queries/github-stars'
27+
import { useMobileViewport } from '@/hooks/use-mobile-viewport'
2728

2829
const logger = createLogger('ChatClient')
2930

@@ -51,6 +52,23 @@ function fileToBase64(file: File): Promise<string> {
5152
})
5253
}
5354

55+
interface ChatViewportProps {
56+
children: ReactNode
57+
}
58+
59+
function ChatViewport({ children }: ChatViewportProps) {
60+
const ref = useRef<HTMLDivElement>(null)
61+
useMobileViewport({ ref })
62+
return (
63+
<div
64+
ref={ref}
65+
className='light desktop-title-bar-page fixed inset-0 z-[var(--z-dropdown)] flex flex-col bg-[var(--bg)] text-[var(--text-primary)] max-md:top-[var(--mobile-viewport-top,0px)] max-md:bottom-auto max-md:h-[var(--mobile-viewport-height,100dvh)] max-md:min-h-0!'
66+
>
67+
{children}
68+
</div>
69+
)
70+
}
71+
5472
export default function ChatClient({ identifier }: { identifier: string }) {
5573
const [messages, setMessages] = useState<ChatMessage[]>([])
5674
const [isLoading, setIsLoading] = useState(false)
@@ -309,7 +327,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
309327
}
310328

311329
return (
312-
<div className='light desktop-title-bar-page fixed inset-0 z-[var(--z-dropdown)] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
330+
<ChatViewport>
313331
<DesktopTitleBarLane />
314332
<ChatHeader chatConfig={chatConfig} starCount={starCount} />
315333

@@ -323,7 +341,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
323341
chatConfig={chatConfig}
324342
/>
325343

326-
<div className='relative p-3 pb-4 md:p-4 md:pb-6'>
344+
<div className='relative shrink-0 md:p-4 md:pb-6'>
327345
<div className='relative mx-auto max-w-3xl md:max-w-[748px]'>
328346
<ChatInput
329347
onSubmit={(value, files) => {
@@ -334,6 +352,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
334352
/>
335353
</div>
336354
</div>
337-
</div>
355+
</ChatViewport>
338356
)
339357
}

‎apps/sim/app/(interfaces)/chat/components/header/header.tsx‎

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

3-
import { SimWordmark } from '@sim/emcn'
3+
import { OverflowText, SimWordmark } from '@sim/emcn'
44
import Image from 'next/image'
55
import Link from 'next/link'
66
import { GithubIcon } from '@/components/icons'
@@ -27,33 +27,35 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
2727
return (
2828
<nav
2929
aria-label='Chat navigation'
30-
className='flex w-full items-center justify-between px-4 pt-3 pb-[21px] sm:px-8 sm:pt-[8.5px] md:px-[44px] md:pt-4'
30+
className='flex w-full shrink-0 items-center justify-between gap-3 px-4 pt-3 pb-[21px] sm:px-8 sm:pt-[8.5px] md:px-[44px] md:pt-4'
3131
>
32-
<div className='flex items-center gap-[34px]'>
33-
<div className='flex items-center gap-3'>
32+
<div className='min-w-0 flex-1'>
33+
<div className='flex min-w-0 items-center gap-3'>
3434
{customImage && (
3535
<Image
3636
src={customImage}
3737
alt={`${chatConfig?.title || 'Chat'} logo`}
3838
width={24}
3939
height={24}
4040
unoptimized
41-
className='size-6 rounded-md object-cover'
41+
className='size-6 shrink-0 rounded-md object-cover'
4242
/>
4343
)}
44-
<h2 className='text-[var(--text-primary)] text-lg'>
45-
{chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
44+
<h2 className='min-w-0 text-[var(--text-primary)] text-lg'>
45+
<OverflowText
46+
label={chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
47+
/>
4648
</h2>
4749
</div>
4850
</div>
4951

5052
{!brand.logoUrl && (
51-
<div className='flex items-center gap-4'>
53+
<div className='flex shrink-0 items-center gap-4'>
5254
<a
5355
href='https://github.com/simstudioai/sim'
5456
target='_blank'
5557
rel='noopener noreferrer'
56-
className='flex items-center gap-2 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-primary)]'
58+
className='hidden items-center gap-2 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-primary)] md:flex'
5759
aria-label={`GitHub repository - ${starCount} stars`}
5860
>
5961
<GithubIcon className='size-[16px]' aria-hidden='true' />

‎apps/sim/app/(interfaces)/chat/components/input/input.tsx‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger'
88
import { generateId } from '@sim/utils/id'
99
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
1010
import { PublicChatActionButton } from '@/app/(interfaces)/chat/components/input/public-chat-action-button'
11+
import { isMobileViewport } from '@/hooks/use-is-mobile'
1112

1213
const logger = createLogger('ChatInput')
1314

@@ -49,11 +50,9 @@ export const ChatInput: React.FC<{
4950
const maxSize = 10 * 1024 * 1024
5051
const maxFiles = 15
5152

52-
for (let i = 0; i < selectedFiles.length; i++) {
53+
for (const file of Array.from(selectedFiles)) {
5354
if (attachedFiles.length + newFiles.length >= maxFiles) break
5455

55-
const file = selectedFiles[i]
56-
5756
if (file.size > maxSize) {
5857
setUploadErrors((prev) => [...prev, `${file.name} is too large (max 10MB)`])
5958
continue
@@ -111,7 +110,7 @@ export const ChatInput: React.FC<{
111110
}
112111

113112
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
114-
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
113+
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && !isMobileViewport()) {
115114
e.preventDefault()
116115
handleSubmit()
117116
}
@@ -125,7 +124,7 @@ export const ChatInput: React.FC<{
125124
const canSubmit = (inputValue.trim().length > 0 || attachedFiles.length > 0) && !isStreaming
126125

127126
return (
128-
<div className='fixed right-0 bottom-0 left-0 flex w-full items-center justify-center bg-linear-to-t from-[var(--bg)] to-transparent px-4 pb-4 md:px-0 md:pb-4'>
127+
<div className='relative flex w-full shrink-0 items-center justify-center bg-linear-to-t from-[var(--bg)] to-transparent px-3 pb-[max(12px,env(safe-area-inset-bottom))] md:fixed md:right-0 md:bottom-0 md:left-0 md:px-0 md:pb-4'>
129128
<div className='w-full max-w-3xl md:max-w-[748px]'>
130129
{uploadErrors.length > 0 && (
131130
<div className='mb-3 flex flex-col gap-2'>
@@ -168,7 +167,7 @@ export const ChatInput: React.FC<{
168167
}}
169168
>
170169
{attachedFiles.length > 0 && (
171-
<div className='mb-1.5 flex flex-wrap gap-1.5'>
170+
<div className='mb-1.5 flex flex-wrap gap-1.5 max-md:max-h-24 max-md:overflow-y-auto max-md:overscroll-contain'>
172171
{attachedFiles.map((file) => (
173172
<Tooltip.Root key={file.id}>
174173
<Tooltip.Trigger asChild>
@@ -194,9 +193,11 @@ export const ChatInput: React.FC<{
194193
e.stopPropagation()
195194
handleRemoveFile(file.id)
196195
}}
197-
className='absolute top-[2px] right-[2px] size-[16px] rounded-full bg-black/60 p-0 text-white opacity-0 hover-hover:text-white group-hover:opacity-100'
196+
className='absolute top-[2px] right-[2px] size-11 items-start justify-end p-0 focus-visible:opacity-100 md:size-[16px] md:opacity-0 md:group-hover:opacity-100'
198197
>
199-
<X className='size-[10px]' />
198+
<span className='flex size-7 items-center justify-center rounded-full bg-black/60 text-white md:size-4'>
199+
<X className='size-[10px]' />
200+
</span>
200201
</Button>
201202
</div>
202203
</Tooltip.Trigger>
@@ -215,7 +216,8 @@ export const ChatInput: React.FC<{
215216
onKeyDown={handleKeyDown}
216217
placeholder={isDragOver ? 'Drop files here...' : 'Enter a message...'}
217218
rows={1}
218-
className='m-0 h-auto min-h-[24px] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent p-1 text-[15px] text-[var(--text-primary)] leading-[24px] caret-[var(--text-primary)] outline-hidden [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden'
219+
aria-label='Message'
220+
className='m-0 h-auto min-h-[24px] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent p-1 text-[var(--text-primary)] text-md leading-[24px] caret-[var(--text-primary)] outline-hidden [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 max-md:max-h-[min(200px,calc(var(--mobile-viewport-height,100dvh)*0.25))] md:text-base [&::-webkit-scrollbar]:hidden'
219221
/>
220222

221223
<div className='flex items-center justify-between'>

‎apps/sim/app/(interfaces)/chat/components/input/public-chat-action-button.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ interface PublicChatActionButtonProps
1212

1313
/** Public chat's circular composer action, retaining its primary and quiet palettes. */
1414
export const PublicChatActionButton = forwardRef<HTMLButtonElement, PublicChatActionButtonProps>(
15-
(props, ref) => <Button {...props} ref={ref} className='size-[28px] rounded-full p-0' />
15+
(props, ref) => (
16+
<Button
17+
{...props}
18+
ref={ref}
19+
className='size-[44px] rounded-full bg-clip-content p-2 md:size-[28px] md:p-0'
20+
/>
21+
)
1622
)
1723
PublicChatActionButton.displayName = 'PublicChatActionButton'

‎apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ export function ChatMessageContainer({
3030
chatConfig,
3131
}: ChatMessageContainerProps) {
3232
return (
33-
<div className='relative flex flex-1 flex-col overflow-hidden'>
33+
<div className='relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden'>
3434
<div
3535
ref={messagesContainerRef}
36-
className='absolute inset-0 touch-pan-y overflow-y-auto overscroll-auto scroll-smooth'
36+
className='absolute inset-0 touch-pan-y overflow-y-auto overscroll-contain scroll-smooth motion-reduce:scroll-auto'
3737
>
38-
<div className='mx-auto max-w-3xl px-4 pt-10 pb-20'>
38+
<div className='mx-auto max-w-3xl px-3 pt-4 pb-4 md:px-4 md:pt-10 md:pb-20'>
3939
{messages.length === 0 ? (
4040
<div className='flex flex-col items-center justify-center py-10'>
4141
<div className='space-y-2 text-center'>
@@ -68,7 +68,7 @@ export function ChatMessageContainer({
6868
</div>
6969

7070
{showScrollButton && (
71-
<div className='-translate-x-1/2 absolute bottom-16 left-1/2 z-20'>
71+
<div className='-translate-x-1/2 absolute bottom-4 left-1/2 z-20 md:bottom-16'>
7272
<Button
7373
onClick={scrollToBottom}
7474
size='sm'

0 commit comments

Comments
 (0)