Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
4 changes: 4 additions & 0 deletions .agents/skills/emcn-design-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ Use `ChipConfirmModal` (title "Delete/Remove {ItemType}", `confirm={{ label, onC

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.

## Mobile

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.

## Anti-patterns to flag

- Raw `<button>`/`<input>`, or legacy `Input`/`Textarea`/`Modal`, instead of the canonical chip components (`ChipInput`/`ChipTextarea`/`ChipModal`)
Expand Down
5 changes: 3 additions & 2 deletions .claude/rules/sim-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ contracts, and demonstrated regressions.
(`sim_test`); `TEST_REDIS_URL` must be loopback. `packages/db/testing/test-infrastructure.ts` owns
those checks. Isolate with a unique schema or generated IDs, and clean up in `afterAll`.
- Integration files run one at a time against one shared database. A new `*.integration.ts` is
picked up by CI with no workflow change, and the run writes `test-results/integration.json`, which
CI uploads. Never add a passing suite to the quarantine list in `apps/sim/vitest.config.ts`.
picked up by CI with no workflow change, and the run writes the JSON report configured in
`vitest.shared.ts`, which CI uploads. Never add a passing suite to the quarantine list in
`apps/sim/vitest.config.ts`.
- `bun run test:integration` starts disposable Postgres and Redis containers, provisions the schema,
and runs both workspaces; pass filenames to narrow the `apps/sim` run.

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

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

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

mobile)
export NEXT_PUBLIC_FORCE_HOSTED=false
export INTERNAL_API_SECRET=mobile-http-ci-local-secret-at-least-32-characters
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--max-old-space-size=12288"
bunx --no-install playwright install --with-deps chromium webkit
start_app mobile 3024 'mobile browser' record-http-status
MOBILE_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
MOBILE_E2E_DATABASE_URL="$DATABASE_URL" \
MOBILE_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
MOBILE_E2E_REPORT_PATH="$report_dir/mobile-e2e-report.json" \
bun run test:mobile:e2e
;;

*)
echo "::error::Unknown end-to-end group: $group" >&2
exit 2
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,12 @@ jobs:
# The suites exercise HTTP behavior rather than a provisioning path, so they run once, against
# the production (migrate) path. Each group's app environment lives in http-e2e.sh.
#
# SCIM runs two suites against a hosted app and is the longest group, so it keeps the 8 vCPU
# runner; the others boot a smaller self-hosted app and fit on 4.
# SCIM's hosted suites and the mobile browser matrix use 8 vCPU; the other
# self-hosted suites fit on 4.
e2e:
name: e2e (${{ matrix.group }})
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.runner || 'ubuntu-latest' }}
timeout-minutes: 20
timeout-minutes: ${{ matrix.group == 'mobile' && 40 || 20 }}
strategy:
fail-fast: false
matrix:
Expand All @@ -156,6 +156,8 @@ jobs:
runner: blacksmith-4vcpu-ubuntu-2404
- group: desktop-inbox
runner: blacksmith-4vcpu-ubuntu-2404
- group: mobile
runner: blacksmith-8vcpu-ubuntu-2404
services:
postgres:
image: pgvector/pgvector:pg17
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/credential-picker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function CredentialPicker({ configuration, api }: CredentialPickerProps) {
align='start'
sideOffset={0}
avoidCollisions={false}
className='w-[320px] max-w-none'
className='w-[320px] max-w-none max-md:max-w-none!'
Comment thread
waleedlatif1 marked this conversation as resolved.
aria-label='Saved passwords'
onOpenAutoFocus={(event) => {
event.preventDefault()
Expand Down
32 changes: 26 additions & 6 deletions apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'

import { type RefObject, useCallback, useMemo, useRef, useState } from 'react'
import { type ReactNode, type RefObject, useCallback, useMemo, useRef, useState } from 'react'
import { usePrefersReducedMotion } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import {
Expand All @@ -24,6 +25,7 @@ import { useChatStreaming } from '@/app/(interfaces)/chat/hooks'
import SSOAuth from '@/ee/sso/components/sso-auth'
import { useDeployedChatConfig } from '@/hooks/queries/chats'
import { useGitHubStars } from '@/hooks/queries/github-stars'
import { useMobileViewport } from '@/hooks/use-mobile-viewport'

const logger = createLogger('ChatClient')

Expand Down Expand Up @@ -51,7 +53,25 @@ function fileToBase64(file: File): Promise<string> {
})
}

interface ChatViewportProps {
children: ReactNode
}

function ChatViewport({ children }: ChatViewportProps) {
const ref = useRef<HTMLDivElement>(null)
useMobileViewport({ ref })
return (
<div
ref={ref}
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!'
>
{children}
</div>
)
}

export default function ChatClient({ identifier }: { identifier: string }) {
const prefersReducedMotion = usePrefersReducedMotion()
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -95,7 +115,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
* With `force` (jump button), re-pins to bottom.
*/
const scrollToBottom = (options?: { behavior?: ScrollBehavior; force?: boolean }) => {
const behavior = options?.behavior ?? 'smooth'
const behavior = prefersReducedMotion ? 'auto' : (options?.behavior ?? 'smooth')
const force = options?.force === true
if (!force && !stickToBottomRef.current) return
if (!messagesEndRef.current) return
Expand Down Expand Up @@ -125,7 +145,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {

container.scrollTo({
top: container.scrollTop + messageRect.top - containerRect.top,
behavior: 'smooth',
behavior: prefersReducedMotion ? 'auto' : 'smooth',
})
}

Expand Down Expand Up @@ -309,7 +329,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}

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

Expand All @@ -323,7 +343,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
chatConfig={chatConfig}
/>

<div className='relative p-3 pb-4 md:p-4 md:pb-6'>
<div className='relative shrink-0 md:p-4 md:pb-6'>
<div className='relative mx-auto max-w-3xl md:max-w-[748px]'>
<ChatInput
onSubmit={(value, files) => {
Expand All @@ -334,6 +354,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
/>
</div>
</div>
</div>
</ChatViewport>
)
}
20 changes: 11 additions & 9 deletions apps/sim/app/(interfaces)/chat/components/header/header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { SimWordmark } from '@sim/emcn'
import { OverflowText, SimWordmark } from '@sim/emcn'
import Image from 'next/image'
import Link from 'next/link'
import { GithubIcon } from '@/components/icons'
Expand All @@ -27,33 +27,35 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
return (
<nav
aria-label='Chat navigation'
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'
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'
>
<div className='flex items-center gap-[34px]'>
<div className='flex items-center gap-3'>
<div className='min-w-0 flex-1'>
<div className='flex min-w-0 items-center gap-3'>
{customImage && (
<Image
src={customImage}
alt={`${chatConfig?.title || 'Chat'} logo`}
width={24}
height={24}
unoptimized
className='size-6 rounded-md object-cover'
className='size-6 shrink-0 rounded-md object-cover'
/>
)}
<h2 className='text-[var(--text-primary)] text-lg'>
{chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
<h2 className='min-w-0 text-[var(--text-primary)] text-lg'>
<OverflowText
label={chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
/>
</h2>
</div>
</div>

{!brand.logoUrl && (
<div className='flex items-center gap-4'>
<div className='flex shrink-0 items-center gap-4'>
<a
href='https://github.com/simstudioai/sim'
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-2 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-primary)]'
className='hidden items-center gap-2 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-primary)] md:flex'
aria-label={`GitHub repository - ${starCount} stars`}
>
<GithubIcon className='size-[16px]' aria-hidden='true' />
Expand Down
25 changes: 16 additions & 9 deletions apps/sim/app/(interfaces)/chat/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { PublicChatActionButton } from '@/app/(interfaces)/chat/components/input/public-chat-action-button'
import { isTouchMobileViewport } from '@/hooks/use-is-mobile'

const logger = createLogger('ChatInput')

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

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

const file = selectedFiles[i]

if (file.size > maxSize) {
setUploadErrors((prev) => [...prev, `${file.name} is too large (max 10MB)`])
continue
Expand Down Expand Up @@ -111,7 +110,12 @@ export const ChatInput: React.FC<{
}

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.nativeEvent.isComposing &&
!isTouchMobileViewport()
) {
e.preventDefault()
handleSubmit()
}
Expand All @@ -125,7 +129,7 @@ export const ChatInput: React.FC<{
const canSubmit = (inputValue.trim().length > 0 || attachedFiles.length > 0) && !isStreaming

return (
<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'>
<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'>
<div className='w-full max-w-3xl md:max-w-[748px]'>
{uploadErrors.length > 0 && (
<div className='mb-3 flex flex-col gap-2'>
Expand Down Expand Up @@ -168,7 +172,7 @@ export const ChatInput: React.FC<{
}}
>
{attachedFiles.length > 0 && (
<div className='mb-1.5 flex flex-wrap gap-1.5'>
<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'>
Comment thread
waleedlatif1 marked this conversation as resolved.
Outdated
{attachedFiles.map((file) => (
<Tooltip.Root key={file.id}>
<Tooltip.Trigger asChild>
Expand All @@ -194,9 +198,11 @@ export const ChatInput: React.FC<{
e.stopPropagation()
handleRemoveFile(file.id)
}}
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'
className='absolute top-[2px] right-[2px] size-11 items-start justify-end p-0 focus-visible:opacity-100 md:pointer-fine:size-[16px] md:pointer-fine:opacity-0 md:pointer-fine:group-hover:opacity-100'
>
<X className='size-[10px]' />
<span className='flex size-7 items-center justify-center rounded-full bg-black/60 text-white md:pointer-fine:size-4'>
<X className='size-[10px]' />
</span>
</Button>
</div>
</Tooltip.Trigger>
Expand All @@ -215,7 +221,8 @@ export const ChatInput: React.FC<{
onKeyDown={handleKeyDown}
placeholder={isDragOver ? 'Drop files here...' : 'Enter a message...'}
rows={1}
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'
aria-label='Message'
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'
/>

<div className='flex items-center justify-between'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ interface PublicChatActionButtonProps

/** Public chat's circular composer action, retaining its primary and quiet palettes. */
export const PublicChatActionButton = forwardRef<HTMLButtonElement, PublicChatActionButtonProps>(
(props, ref) => <Button {...props} ref={ref} className='size-[28px] rounded-full p-0' />
(props, ref) => (
<Button
{...props}
ref={ref}
className='size-[44px] rounded-full bg-clip-content p-2 md:size-[28px] md:p-0'
/>
)
)
PublicChatActionButton.displayName = 'PublicChatActionButton'
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ export function ChatMessageContainer({
chatConfig,
}: ChatMessageContainerProps) {
return (
<div className='relative flex flex-1 flex-col overflow-hidden'>
<div className='relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden'>
<div
ref={messagesContainerRef}
className='absolute inset-0 touch-pan-y overflow-y-auto overscroll-auto scroll-smooth'
className='absolute inset-0 touch-pan-y overflow-y-auto overscroll-contain scroll-smooth motion-reduce:scroll-auto'
Comment thread
waleedlatif1 marked this conversation as resolved.
>
<div className='mx-auto max-w-3xl px-4 pt-10 pb-20'>
<div className='mx-auto max-w-3xl px-3 pt-4 pb-4 md:px-4 md:pt-10 md:pb-20'>
{messages.length === 0 ? (
<div className='flex flex-col items-center justify-center py-10'>
<div className='space-y-2 text-center'>
Expand Down Expand Up @@ -68,7 +68,7 @@ export function ChatMessageContainer({
</div>

{showScrollButton && (
<div className='-translate-x-1/2 absolute bottom-16 left-1/2 z-20'>
<div className='-translate-x-1/2 absolute bottom-4 left-1/2 z-20 md:bottom-16'>
<Button
onClick={scrollToBottom}
size='sm'
Expand Down
Loading
Loading