mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
feat(desktop): start H5 phone tasks from a new-task sheet (#1461)
The phone home is now the session list alone. The docked composer took a quarter of the screen and could not send until a project was picked, so a New task button floats over the list instead and opens a full-height sheet. - The sheet puts the project at the top, above the keyboard, and docks the composer to the bottom. Cancel, the system back gesture or pulling the bar down closes it; sending swaps it for the new session. - The project defaults to the list's project filter, otherwise to the project of the most recently created task. - On a tablet the button opens the new-session page beside the list.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { EmptySession } from '../../pages/EmptySession'
|
||||
|
||||
/** How far the sheet must be pulled down to close on release. */
|
||||
export const SHEET_DISMISS_DISTANCE_PX = 120
|
||||
/** A quick flick closes it from a shorter pull, in px per ms. */
|
||||
const SHEET_DISMISS_VELOCITY = 0.6
|
||||
const SHEET_FLICK_MIN_DISTANCE_PX = 24
|
||||
|
||||
type Props = {
|
||||
/** The folder the task starts in; the person can change it at the top. */
|
||||
workDir: string
|
||||
/** Leaves without starting anything. The shell routes it through history. */
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone's new task: a full-height sheet over the session list, with the
|
||||
* project at the top and the composer docked to the bottom. Cancel, the system
|
||||
* back gesture or pulling the bar down closes it; sending opens the new
|
||||
* session, and the shell takes the sheet away as the page changes.
|
||||
*
|
||||
* Only the bar at the top is draggable. The page under it scrolls and holds a
|
||||
* text field, and a pull that starts there belongs to them.
|
||||
*/
|
||||
export function MobileNewTaskSheet({ workDir, onCancel }: Props) {
|
||||
const t = useTranslation()
|
||||
const [dragY, setDragY] = useState(0)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const dragRef = useRef<{ pointerId: number; startY: number; startTime: number } | null>(null)
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!event.isPrimary || (event.target as Element).closest('button')) return
|
||||
dragRef.current = { pointerId: event.pointerId, startY: event.clientY, startTime: event.timeStamp }
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
setDragging(true)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.pointerId !== event.pointerId) return
|
||||
setDragY(Math.max(0, event.clientY - drag.startY))
|
||||
}
|
||||
|
||||
const handlePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.pointerId !== event.pointerId) return
|
||||
dragRef.current = null
|
||||
setDragging(false)
|
||||
const distance = Math.max(0, event.clientY - drag.startY)
|
||||
const elapsed = Math.max(1, event.timeStamp - drag.startTime)
|
||||
const flicked = distance >= SHEET_FLICK_MIN_DISTANCE_PX && distance / elapsed >= SHEET_DISMISS_VELOCITY
|
||||
if (event.type === 'pointerup' && (distance >= SHEET_DISMISS_DISTANCE_PX || flicked)) {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
setDragY(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="mobile-new-task-sheet" className="absolute inset-0 z-[var(--z-sheet)]">
|
||||
<div aria-hidden="true" className="animate-overlay-in absolute inset-0 bg-[var(--color-overlay-scrim)]" onClick={onCancel} />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('mobile.newTask.title')}
|
||||
style={dragY > 0 ? { transform: `translateY(${dragY}px)` } : undefined}
|
||||
className={`animate-sheet-rise absolute inset-x-0 bottom-0 top-[calc(env(safe-area-inset-top,0px)+8px)] flex flex-col overflow-hidden rounded-t-[var(--radius-xl)] bg-[var(--color-surface)] shadow-[var(--shadow-overlay)] ${
|
||||
dragging ? '' : 'transition-transform duration-200 ease-out'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
data-testid="mobile-new-task-grabber"
|
||||
className="shrink-0 touch-none select-none"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
>
|
||||
<div aria-hidden="true" className="mx-auto mt-2 h-1 w-9 rounded-[var(--radius-full)] bg-[var(--color-outline)]" />
|
||||
<div className="grid h-12 grid-cols-[1fr_auto_1fr] items-center px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="h-11 justify-self-start rounded-[var(--radius-md)] px-3 text-[15px] text-[var(--color-text-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<h2 className="text-[15px] font-semibold text-[var(--color-text-primary)]">{t('mobile.newTask.title')}</h2>
|
||||
<span aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<EmptySession initialWorkDir={workDir} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Search, Settings, X } from 'lucide-react'
|
||||
import { Plus, Search, Settings, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { EmptyState } from '@/components/ui/EmptyState'
|
||||
import { ErrorState } from '@/components/ui/ErrorState'
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { getSessionProjectKey, getSessionWorkspaceLabel } from '../layout/sidebarTaskGroups'
|
||||
import { MobileSessionList } from './MobileSessionList'
|
||||
import { openMobileSession } from './mobileNavigation'
|
||||
import { resolveNewTaskWorkDir } from './mobileNewTask'
|
||||
import {
|
||||
buildMobileSessionGroups,
|
||||
filterMobileSessions,
|
||||
@@ -33,19 +34,22 @@ import { useLiveSessionActivity } from './useLiveSessionActivity'
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* `home` is the phone's first page (the composer sits below it); `pane` is
|
||||
* the tablet's left column, where the open session is shown beside it.
|
||||
* `home` is the phone's first page; `pane` is the tablet's left column,
|
||||
* where the open session is shown beside it.
|
||||
*/
|
||||
variant: 'home' | 'pane'
|
||||
selectedSessionId?: string | null
|
||||
preferencesRequest?: Promise<DesktopUiPreferencesResponse> | null
|
||||
/** Starts a new task, in the folder the list suggests (empty: none). */
|
||||
onNewTask: (workDir: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Header, search, project filter and the status-grouped session list: what
|
||||
* the phone opens on, and what the tablet keeps on its left.
|
||||
* Header, search, project filter and the status-grouped session list, with
|
||||
* the New task button floating over its lower corner: what the phone opens
|
||||
* on, and what the tablet keeps on its left.
|
||||
*/
|
||||
export function MobileSessionBrowser({ variant, selectedSessionId = null, preferencesRequest = null }: Props) {
|
||||
export function MobileSessionBrowser({ variant, selectedSessionId = null, preferencesRequest = null, onNewTask }: Props) {
|
||||
const t = useTranslation()
|
||||
const sessions = useSessionStore((state) => state.sessions)
|
||||
const isLoading = useSessionStore((state) => state.isLoading)
|
||||
@@ -114,7 +118,7 @@ export function MobileSessionBrowser({ variant, selectedSessionId = null, prefer
|
||||
return (
|
||||
<div
|
||||
data-testid={`mobile-session-browser-${variant}`}
|
||||
className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]"
|
||||
className="relative flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]"
|
||||
>
|
||||
<header className="flex h-14 shrink-0 items-center gap-1 pl-4 pr-1">
|
||||
<h1 className="min-w-0 flex-1 truncate text-[22px] font-semibold leading-tight tracking-tight text-[var(--color-text-primary)]">
|
||||
@@ -174,7 +178,8 @@ export function MobileSessionBrowser({ variant, selectedSessionId = null, prefer
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
{/* The bottom padding lets the last row scroll clear of the button. */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-24">
|
||||
{showInitialLoading ? (
|
||||
<div className="px-4 pt-2"><SkeletonRows count={6} divided label={t('common.loading')} /></div>
|
||||
) : error && sessions.length === 0 ? (
|
||||
@@ -200,6 +205,16 @@ export function MobileSessionBrowser({ variant, selectedSessionId = null, prefer
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mobile-new-task"
|
||||
onClick={() => onNewTask(resolveNewTaskWorkDir(visibleSessions, effectiveProjectKey))}
|
||||
className="absolute bottom-5 right-4 z-[var(--z-raised)] inline-flex h-12 items-center gap-1.5 rounded-[var(--radius-full)] bg-[var(--color-text-primary)] pl-4 pr-5 text-[15px] font-medium text-[var(--color-surface)] shadow-[var(--shadow-overlay)] transition-transform active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2"
|
||||
>
|
||||
<Plus size={18} strokeWidth={2} aria-hidden="true" />
|
||||
{t('mobile.home.newTask')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,11 +41,8 @@ vi.mock('../layout/ContentRouter', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../pages/EmptySession', () => ({
|
||||
EmptySession: ({ mobileHome }: { mobileHome?: ReactNode }) => (
|
||||
<div>
|
||||
{mobileHome}
|
||||
<div>new task composer</div>
|
||||
</div>
|
||||
EmptySession: ({ initialWorkDir }: { initialWorkDir?: string }) => (
|
||||
<div data-testid="new-task-page">new task in {initialWorkDir || '(no project)'}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -53,6 +50,7 @@ vi.mock('../layout/UpdateChecker', () => ({ UpdateChecker: () => null }))
|
||||
vi.mock('../layout/Toast', () => ({ ToastContainer: () => null }))
|
||||
|
||||
import { MobileShell } from './MobileShell'
|
||||
import { SHEET_DISMISS_DISTANCE_PX } from './MobileNewTaskSheet'
|
||||
|
||||
class TestPointerEvent extends MouseEvent {
|
||||
pointerType: string
|
||||
@@ -64,7 +62,7 @@ class TestPointerEvent extends MouseEvent {
|
||||
}
|
||||
}
|
||||
|
||||
function session(id: string, title: string, minutesAgo = 5): SessionListItem {
|
||||
function session(id: string, title: string, minutesAgo = 5, project = '/work/cc-haha'): SessionListItem {
|
||||
const modifiedAt = new Date(Date.now() - minutesAgo * 60_000).toISOString()
|
||||
return {
|
||||
id,
|
||||
@@ -72,9 +70,9 @@ function session(id: string, title: string, minutesAgo = 5): SessionListItem {
|
||||
createdAt: modifiedAt,
|
||||
modifiedAt,
|
||||
messageCount: 3,
|
||||
projectPath: '/work/cc-haha',
|
||||
projectRoot: '/work/cc-haha',
|
||||
workDir: '/work/cc-haha',
|
||||
projectPath: project,
|
||||
projectRoot: project,
|
||||
workDir: project,
|
||||
workDirExists: true,
|
||||
}
|
||||
}
|
||||
@@ -84,10 +82,14 @@ const disconnectSession = vi.fn()
|
||||
const renameSession = vi.fn(async () => undefined)
|
||||
const deleteSession = vi.fn(async () => undefined)
|
||||
|
||||
function seed({ tabs = [], activeTabId = null }: { tabs?: Tab[]; activeTabId?: string | null } = {}) {
|
||||
function seed({
|
||||
tabs = [],
|
||||
activeTabId = null,
|
||||
sessions = [session('s-login', 'Fix login i18n'), session('s-release', 'Draft release notes', 90)],
|
||||
}: { tabs?: Tab[]; activeTabId?: string | null; sessions?: SessionListItem[] } = {}) {
|
||||
useTabStore.setState({ tabs, activeTabId })
|
||||
useSessionStore.setState({
|
||||
sessions: [session('s-login', 'Fix login i18n'), session('s-release', 'Draft release notes', 90)],
|
||||
sessions,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
fetchSessions: vi.fn(async () => undefined),
|
||||
@@ -129,10 +131,96 @@ describe('MobileShell', () => {
|
||||
})
|
||||
const sections = within(list).getAllByRole('region')
|
||||
expect(within(sections[0]!).getByText('Draft release notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('new task composer')).toBeInTheDocument()
|
||||
// Home is the list alone; a new task is one tap away, not a composer.
|
||||
expect(screen.queryByTestId('new-task-page')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('mobile-new-task')).toHaveTextContent('mobile.home.newTask')
|
||||
expect(screen.queryByTestId('mobile-top-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens a new task over the list in the filtered project, and the back gesture closes it', async () => {
|
||||
seed({
|
||||
sessions: [
|
||||
session('s-login', 'Fix login i18n'),
|
||||
session('s-crawl', 'Export comments', 60, '/work/media-crawler'),
|
||||
],
|
||||
})
|
||||
renderShell()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'media-crawler' }))
|
||||
fireEvent.click(screen.getByTestId('mobile-new-task'))
|
||||
|
||||
const sheet = screen.getByTestId('mobile-new-task-sheet')
|
||||
expect(within(sheet).getByRole('dialog', { name: 'mobile.newTask.title' })).toBeInTheDocument()
|
||||
expect(within(sheet).getByTestId('new-task-page')).toHaveTextContent('new task in /work/media-crawler')
|
||||
// The list stays underneath, and the sheet is a level the system back leaves.
|
||||
expect(screen.getByTestId('mobile-session-list')).toBeInTheDocument()
|
||||
expect(window.history.state).toEqual({ ccHahaMobileGuard: true })
|
||||
|
||||
act(() => { window.history.back() })
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('mobile-new-task-sheet')).not.toBeInTheDocument())
|
||||
expect(useTabStore.getState().activeTabId).toBeNull()
|
||||
expect(window.history.state).toBeNull()
|
||||
})
|
||||
|
||||
it('starts a new task in the project of the newest task when no filter is picked', async () => {
|
||||
seed({
|
||||
sessions: [
|
||||
// Touched most recently, but started long ago.
|
||||
{ ...session('s-old', 'Long running refactor', 600, '/work/legacy'), modifiedAt: new Date().toISOString() },
|
||||
session('s-new', 'Export comments', 30, '/work/media-crawler'),
|
||||
],
|
||||
})
|
||||
renderShell()
|
||||
|
||||
fireEvent.click(await screen.findByTestId('mobile-new-task'))
|
||||
|
||||
expect(screen.getByTestId('new-task-page')).toHaveTextContent('new task in /work/media-crawler')
|
||||
})
|
||||
|
||||
it('closes the new task with Cancel or a long enough pull on its bar', async () => {
|
||||
renderShell()
|
||||
|
||||
fireEvent.click(await screen.findByTestId('mobile-new-task'))
|
||||
fireEvent.click(within(screen.getByTestId('mobile-new-task-sheet')).getByRole('button', { name: 'common.cancel' }))
|
||||
await waitFor(() => expect(screen.queryByTestId('mobile-new-task-sheet')).not.toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByTestId('mobile-new-task'))
|
||||
const grabber = screen.getByTestId('mobile-new-task-grabber')
|
||||
// A small nudge springs back.
|
||||
fireEvent.pointerDown(grabber, { clientY: 100 })
|
||||
fireEvent.pointerMove(grabber, { clientY: 115 })
|
||||
fireEvent.pointerUp(grabber, { clientY: 115 })
|
||||
// Closing goes through history.back(), which lands a tick later: wait it out.
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)) })
|
||||
expect(screen.getByTestId('mobile-new-task-sheet')).toBeInTheDocument()
|
||||
expect(window.history.state).toEqual({ ccHahaMobileGuard: true })
|
||||
|
||||
fireEvent.pointerDown(grabber, { clientY: 100 })
|
||||
fireEvent.pointerMove(grabber, { clientY: 100 + SHEET_DISMISS_DISTANCE_PX + 10 })
|
||||
fireEvent.pointerUp(grabber, { clientY: 100 + SHEET_DISMISS_DISTANCE_PX + 10 })
|
||||
await waitFor(() => expect(screen.queryByTestId('mobile-new-task-sheet')).not.toBeInTheDocument())
|
||||
expect(window.history.state).toBeNull()
|
||||
})
|
||||
|
||||
it('swaps the new task for the session it started, with Back going to the list', async () => {
|
||||
renderShell()
|
||||
fireEvent.click(await screen.findByTestId('mobile-new-task'))
|
||||
|
||||
// What sending does: open the new session's tab.
|
||||
act(() => useTabStore.getState().openTab('s-started', 'New Session'))
|
||||
|
||||
expect(screen.queryByTestId('mobile-new-task-sheet')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('routed-page')).toHaveTextContent('page for s-started')
|
||||
expect(window.history.state).toEqual({ ccHahaMobileGuard: true })
|
||||
|
||||
fireEvent.click(screen.getByTestId('mobile-back'))
|
||||
|
||||
await waitFor(() => expect(useTabStore.getState().activeTabId).toBeNull())
|
||||
expect(screen.queryByTestId('mobile-new-task-sheet')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('mobile-session-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('goes into a session and comes back with the Back button', async () => {
|
||||
renderShell()
|
||||
|
||||
@@ -199,6 +287,22 @@ describe('MobileShell', () => {
|
||||
expect(screen.queryByTestId('mobile-back')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a new task beside the list on a tablet instead of a sheet over it', async () => {
|
||||
seed({
|
||||
tabs: [{ sessionId: 's-login', title: 'Fix login i18n', type: 'session', status: 'idle' }],
|
||||
activeTabId: 's-login',
|
||||
})
|
||||
renderShell('tablet')
|
||||
const pane = screen.getByTestId('mobile-tablet-pane')
|
||||
await within(pane).findByText('Fix login i18n')
|
||||
|
||||
fireEvent.click(within(pane).getByTestId('mobile-new-task'))
|
||||
|
||||
expect(useTabStore.getState().activeTabId).toBeNull()
|
||||
expect(screen.getByTestId('new-task-page')).toHaveTextContent('new task in /work/cc-haha')
|
||||
expect(screen.queryByTestId('mobile-new-task-sheet')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('puts the parallel work behind a pill in the session bar, once there is some', async () => {
|
||||
seed({
|
||||
tabs: [{ sessionId: 's-login', title: 'Fix login i18n', type: 'session', status: 'idle' }],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { StatusDot } from '@/components/ui/Badge'
|
||||
import type { DesktopUiPreferencesResponse } from '../../api/desktopUiPreferences'
|
||||
@@ -15,6 +15,7 @@ import { ToastContainer } from '../layout/Toast'
|
||||
import { UpdateChecker } from '../layout/UpdateChecker'
|
||||
import { WorkspaceHeaderProvider } from '../layout/WorkspaceHeaderContext'
|
||||
import { MobileSessionBrowser } from './MobileSessionBrowser'
|
||||
import { MobileNewTaskSheet } from './MobileNewTaskSheet'
|
||||
import { MobileTopBar } from './MobileTopBar'
|
||||
import { MobileActivityPill } from './MobileActivityPill'
|
||||
import type { MobileShellLayout } from './mobileShellLayout'
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
goMobileHome,
|
||||
isMobileRoutableTab,
|
||||
mobileRouteDepth,
|
||||
navigateMobileUp,
|
||||
resolveMobileRoute,
|
||||
useMobileHistoryGuard,
|
||||
type MobileRoute,
|
||||
@@ -32,21 +34,47 @@ type Props = {
|
||||
preferencesRequest: Promise<DesktopUiPreferencesResponse> | null
|
||||
}
|
||||
|
||||
/** A new task asked for from the list, and the folder it starts in. */
|
||||
type NewTaskRequest = { workDir: string; id: number }
|
||||
|
||||
/**
|
||||
* The phone and tablet app frame.
|
||||
*
|
||||
* Phone: one page at a time. Home is the session list with the new-task
|
||||
* composer under it; a session, Settings or a detail page replaces it, with a
|
||||
* Back that the system back gesture also drives.
|
||||
* Phone: one page at a time. Home is the session list; a session, Settings or
|
||||
* a detail page replaces it, with a Back that the system back gesture also
|
||||
* drives. A new task rises over the list as a sheet, one level above it.
|
||||
*
|
||||
* Tablet: the same session list stays on the left and the page sits beside it.
|
||||
* Tablet: the same session list stays on the left and the page sits beside
|
||||
* it; a new task is the new-session page on the right.
|
||||
*/
|
||||
export function MobileShell({ layout, preferencesRequest }: Props) {
|
||||
const tabs = useTabStore((state) => state.tabs)
|
||||
const activeTabId = useTabStore((state) => state.activeTabId)
|
||||
const route = resolveMobileRoute(tabs, activeTabId)
|
||||
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId)
|
||||
const goBack = useMobileHistoryGuard(mobileRouteDepth(route), true)
|
||||
const [newTask, setNewTask] = useState<NewTaskRequest | null>(null)
|
||||
const sheetOpen = layout === 'phone' && newTask !== null
|
||||
const goUp = useCallback(() => {
|
||||
if (sheetOpen) setNewTask(null)
|
||||
else navigateMobileUp()
|
||||
}, [sheetOpen])
|
||||
const goBack = useMobileHistoryGuard(mobileRouteDepth(route) + (sheetOpen ? 1 : 0), true, goUp)
|
||||
|
||||
const nextNewTaskId = useRef(0)
|
||||
const startNewTask = useCallback((workDir: string) => {
|
||||
nextNewTaskId.current += 1
|
||||
setNewTask({ workDir, id: nextNewTaskId.current })
|
||||
// On a tablet the new-session page is what the right side shows at home.
|
||||
if (layout === 'tablet') goMobileHome()
|
||||
}, [layout])
|
||||
|
||||
// Sending opens the new session (a slash command may open Settings): the
|
||||
// page changing is what takes the sheet away.
|
||||
const sheetTabIdRef = useRef(activeTabId)
|
||||
useEffect(() => {
|
||||
if (sheetTabIdRef.current !== activeTabId && layout === 'phone') setNewTask(null)
|
||||
sheetTabIdRef.current = activeTabId
|
||||
}, [activeTabId, layout])
|
||||
|
||||
// A desktop-only tab (terminal, scheduled tasks, the team canvas…) restored
|
||||
// from storage or opened by a link has no page here: show home instead of a
|
||||
@@ -72,6 +100,7 @@ export function MobileShell({ layout, preferencesRequest }: Props) {
|
||||
variant="pane"
|
||||
selectedSessionId={route.kind === 'session' ? route.tabId : null}
|
||||
preferencesRequest={preferencesRequest}
|
||||
onNewTask={startNewTask}
|
||||
/>
|
||||
</aside>
|
||||
) : null}
|
||||
@@ -85,11 +114,13 @@ export function MobileShell({ layout, preferencesRequest }: Props) {
|
||||
<WorkspaceHeaderProvider>
|
||||
<ContentRouter
|
||||
homePage={layout === 'phone'
|
||||
? <EmptySession mobileHome={<MobileSessionBrowser variant="home" preferencesRequest={preferencesRequest} />} />
|
||||
: undefined}
|
||||
? <MobileSessionBrowser variant="home" preferencesRequest={preferencesRequest} onNewTask={startNewTask} />
|
||||
// Keyed so each New task starts clean, in the folder it asked for.
|
||||
: <EmptySession key={newTask?.id ?? 0} initialWorkDir={newTask?.workDir} />}
|
||||
/>
|
||||
</WorkspaceHeaderProvider>
|
||||
</main>
|
||||
{sheetOpen ? <MobileNewTaskSheet key={newTask.id} workDir={newTask.workDir} onCancel={goBack} /> : null}
|
||||
<ToastContainer />
|
||||
<UpdateChecker />
|
||||
</div>
|
||||
|
||||
@@ -102,10 +102,19 @@ function isGuardState(state: unknown): state is HistoryGuardState {
|
||||
* the two paths stay one path. When the shell reaches home some other way (a
|
||||
* deleted session, a redirect), the spare entry is consumed quietly so the
|
||||
* next back leaves the app as expected instead of doing nothing.
|
||||
*
|
||||
* `goUp` is what one level up means; the shell passes its own when something
|
||||
* other than a route (the new-task sheet) is the top level.
|
||||
*/
|
||||
export function useMobileHistoryGuard(depth: number, enabled: boolean): () => void {
|
||||
export function useMobileHistoryGuard(
|
||||
depth: number,
|
||||
enabled: boolean,
|
||||
goUp: () => void = navigateMobileUp,
|
||||
): () => void {
|
||||
const guardedRef = useRef(false)
|
||||
const ignoreNextPopRef = useRef(false)
|
||||
const goUpRef = useRef(goUp)
|
||||
goUpRef.current = goUp
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
@@ -130,7 +139,7 @@ export function useMobileHistoryGuard(depth: number, enabled: boolean): () => vo
|
||||
}
|
||||
if (!guardedRef.current) return
|
||||
guardedRef.current = false
|
||||
navigateMobileUp()
|
||||
goUpRef.current()
|
||||
}
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
return () => window.removeEventListener('popstate', handlePopState)
|
||||
@@ -141,6 +150,6 @@ export function useMobileHistoryGuard(depth: number, enabled: boolean): () => vo
|
||||
window.history.back()
|
||||
return
|
||||
}
|
||||
navigateMobileUp()
|
||||
goUpRef.current()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionListItem } from '../../types/session'
|
||||
import { resolveNewTaskWorkDir } from './mobileNewTask'
|
||||
|
||||
function session(id: string, createdAt: string, overrides: Partial<SessionListItem> = {}): SessionListItem {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
createdAt,
|
||||
modifiedAt: createdAt,
|
||||
messageCount: 1,
|
||||
projectPath: `/work/${id}`,
|
||||
projectRoot: `/work/${id}`,
|
||||
workDir: `/work/${id}`,
|
||||
workDirExists: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveNewTaskWorkDir', () => {
|
||||
const sessions = [
|
||||
session('older', '2026-10-01T10:00:00.000Z'),
|
||||
session('newer', '2026-10-05T10:00:00.000Z'),
|
||||
]
|
||||
|
||||
it('starts in the filtered project', () => {
|
||||
expect(resolveNewTaskWorkDir(sessions, '/work/older')).toBe('/work/older')
|
||||
})
|
||||
|
||||
it('otherwise starts where the newest task was created', () => {
|
||||
expect(resolveNewTaskWorkDir(sessions, null)).toBe('/work/newer')
|
||||
})
|
||||
|
||||
it('starts a task last run in a worktree in the repository itself', () => {
|
||||
const worktree = session('repo', '2026-10-06T10:00:00.000Z', {
|
||||
projectRoot: '/work/repo',
|
||||
workDir: '/work/repo/.claude/worktrees/fix',
|
||||
})
|
||||
expect(resolveNewTaskWorkDir([...sessions, worktree], null)).toBe('/work/repo')
|
||||
})
|
||||
|
||||
it('passes over folders that are gone and sessions with no folder', () => {
|
||||
const gone = session('gone', '2026-10-06T10:00:00.000Z', { workDirExists: false })
|
||||
const unknown = session('unknown', '2026-10-07T10:00:00.000Z', { projectRoot: null, workDir: null, projectPath: '' })
|
||||
expect(resolveNewTaskWorkDir([...sessions, gone, unknown], null)).toBe('/work/newer')
|
||||
expect(resolveNewTaskWorkDir([unknown], 'unknown')).toBe('')
|
||||
})
|
||||
|
||||
it('leaves the choice open when there is nothing to go on', () => {
|
||||
expect(resolveNewTaskWorkDir([], null)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { SessionListItem } from '../../types/session'
|
||||
import { getSessionProjectKey } from '../layout/sidebarTaskGroups'
|
||||
|
||||
const UNKNOWN_PROJECT_KEY = 'unknown'
|
||||
|
||||
/**
|
||||
* The folder a new task starts in when it is opened from the phone's list.
|
||||
*
|
||||
* With a project filter picked, that project: the person is looking at it.
|
||||
* Otherwise the project of the task created most recently, on any device,
|
||||
* since that is where they last chose to start work. A folder that no longer
|
||||
* exists is skipped rather than offered. Empty when there is nothing to go
|
||||
* on, which leaves the choice to the project picker.
|
||||
*
|
||||
* The project key is the session's project root (or its folder), so a task
|
||||
* last run in a worktree still starts a new one in the repository itself.
|
||||
*/
|
||||
export function resolveNewTaskWorkDir(
|
||||
sessions: readonly SessionListItem[],
|
||||
filterProjectKey: string | null,
|
||||
): string {
|
||||
if (filterProjectKey && filterProjectKey !== UNKNOWN_PROJECT_KEY) return filterProjectKey
|
||||
|
||||
let newest: SessionListItem | null = null
|
||||
for (const session of sessions) {
|
||||
if (session.workDirExists === false) continue
|
||||
if (getSessionProjectKey(session) === UNKNOWN_PROJECT_KEY) continue
|
||||
if (!newest || Date.parse(session.createdAt) > Date.parse(newest.createdAt)) newest = session
|
||||
}
|
||||
return newest ? getSessionProjectKey(newest) : ''
|
||||
}
|
||||
@@ -4273,9 +4273,11 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'mobile.home.closeSearch': "Close search",
|
||||
'mobile.home.projectFilter': "Show sessions from",
|
||||
'mobile.home.allProjects': "All",
|
||||
'mobile.home.empty': "No sessions yet. Describe a task below to start one.",
|
||||
'mobile.home.empty': "No sessions yet. Tap New task to start one.",
|
||||
'mobile.home.emptyPane': "No sessions yet.",
|
||||
'mobile.home.noMatches': "No sessions match.",
|
||||
'mobile.home.newTask': "New task",
|
||||
'mobile.newTask.title': "New task",
|
||||
'mobile.group.attention': "Waiting for you",
|
||||
'mobile.session.waiting': "Waiting for you",
|
||||
'mobile.session.running': "Working",
|
||||
|
||||
@@ -4274,9 +4274,11 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'mobile.home.closeSearch': "検索を閉じる",
|
||||
'mobile.home.projectFilter': "プロジェクトで絞り込み",
|
||||
'mobile.home.allProjects': "すべて",
|
||||
'mobile.home.empty': "まだセッションがありません。下にタスクを書くと始められます。",
|
||||
'mobile.home.empty': "まだセッションがありません。右下の「新しいタスク」から始められます。",
|
||||
'mobile.home.emptyPane': "まだセッションがありません。",
|
||||
'mobile.home.noMatches': "一致するセッションはありません。",
|
||||
'mobile.home.newTask': "新しいタスク",
|
||||
'mobile.newTask.title': "新しいタスク",
|
||||
'mobile.group.attention': "あなたの対応待ち",
|
||||
'mobile.session.waiting': "対応待ち",
|
||||
'mobile.session.running': "実行中",
|
||||
|
||||
@@ -4276,9 +4276,11 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'mobile.home.closeSearch': "검색 닫기",
|
||||
'mobile.home.projectFilter': "프로젝트별 보기",
|
||||
'mobile.home.allProjects': "전체",
|
||||
'mobile.home.empty': "아직 세션이 없습니다. 아래에 작업을 적으면 시작됩니다.",
|
||||
'mobile.home.empty': "아직 세션이 없습니다. 오른쪽 아래의 「새 작업」으로 시작하세요.",
|
||||
'mobile.home.emptyPane': "아직 세션이 없습니다.",
|
||||
'mobile.home.noMatches': "일치하는 세션이 없습니다.",
|
||||
'mobile.home.newTask': "새 작업",
|
||||
'mobile.newTask.title': "새 작업",
|
||||
'mobile.group.attention': "확인 필요",
|
||||
'mobile.session.waiting': "확인 필요",
|
||||
'mobile.session.running': "실행 중",
|
||||
|
||||
@@ -4273,9 +4273,11 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'mobile.home.closeSearch': "關閉搜尋",
|
||||
'mobile.home.projectFilter': "依專案篩選",
|
||||
'mobile.home.allProjects': "全部",
|
||||
'mobile.home.empty': "還沒有工作階段。在下方描述一個任務即可開始。",
|
||||
'mobile.home.empty': "還沒有工作階段。點右下角的「新任務」開始。",
|
||||
'mobile.home.emptyPane': "還沒有工作階段。",
|
||||
'mobile.home.noMatches': "沒有相符的工作階段。",
|
||||
'mobile.home.newTask': "新任務",
|
||||
'mobile.newTask.title': "新任務",
|
||||
'mobile.group.attention': "等你處理",
|
||||
'mobile.session.waiting': "等你處理",
|
||||
'mobile.session.running': "執行中",
|
||||
|
||||
@@ -4272,9 +4272,11 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'mobile.home.closeSearch': "关闭搜索",
|
||||
'mobile.home.projectFilter': "按项目筛选",
|
||||
'mobile.home.allProjects': "全部",
|
||||
'mobile.home.empty': "还没有会话。在下方描述一个任务就能开始。",
|
||||
'mobile.home.empty': "还没有会话。点右下角的「新任务」开始。",
|
||||
'mobile.home.emptyPane': "还没有会话。",
|
||||
'mobile.home.noMatches': "没有匹配的会话。",
|
||||
'mobile.home.newTask': "新任务",
|
||||
'mobile.newTask.title': "新任务",
|
||||
'mobile.group.attention': "等你处理",
|
||||
'mobile.session.waiting': "等你处理",
|
||||
'mobile.session.running': "运行中",
|
||||
|
||||
@@ -377,28 +377,52 @@ describe('EmptySession', () => {
|
||||
expect(screen.getByTestId('empty-session-composer-shell')).toHaveClass('absolute', 'bottom-0')
|
||||
})
|
||||
|
||||
it('puts the phone home list where the hero was, with the composer docked under it', () => {
|
||||
it('puts the project above the hero on a phone, where the keyboard cannot cover it', async () => {
|
||||
mocks.isMobile = true
|
||||
|
||||
render(<EmptySession mobileHome={<div>session list</div>} />)
|
||||
render(<EmptySession />)
|
||||
|
||||
expect(screen.getByTestId('mobile-home')).toHaveTextContent('session list')
|
||||
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument()
|
||||
// In the flow, not floating over the list: the list scrolls above it.
|
||||
const shell = screen.getByTestId('empty-session-composer-shell')
|
||||
expect(shell).toHaveClass('shrink-0')
|
||||
expect(shell).not.toHaveClass('absolute')
|
||||
// Home is read first; the keyboard must not jump up over the list.
|
||||
expect(document.activeElement).not.toBe(screen.getByRole('textbox'))
|
||||
const launch = screen.getByTestId('empty-session-mobile-launch')
|
||||
const pill = await within(launch).findByRole('button', { name: /^Location:/ })
|
||||
expect(pill).toHaveClass('h-10')
|
||||
// Read before the hero and outside the docked composer.
|
||||
expect(launch.compareDocumentPosition(screen.getByRole('heading', { level: 1 })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(screen.getByTestId('empty-session-composer-shell')).not.toContainElement(pill)
|
||||
expect(screen.getAllByRole('button', { name: /^Location:/ })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores the phone home slot on a desktop window', () => {
|
||||
mocks.isMobile = false
|
||||
it('starts in the folder it is given, holding Run until that repository is read', async () => {
|
||||
mocks.isMobile = true
|
||||
// Its own id: the session store keeps per-id bookkeeping across tests.
|
||||
mocks.createSession.mockResolvedValue({ sessionId: 'phone-session' })
|
||||
let resolveContext: (context: RepositoryContextResult) => void = () => {}
|
||||
mocks.getRepositoryContext.mockReturnValue(new Promise<RepositoryContextResult>((resolve) => {
|
||||
resolveContext = resolve
|
||||
}))
|
||||
|
||||
render(<EmptySession mobileHome={<div>session list</div>} />)
|
||||
render(<EmptySession initialWorkDir="/workspace/project" />)
|
||||
setComposerText('draft question', 14)
|
||||
|
||||
expect(screen.queryByTestId('mobile-home')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument()
|
||||
const runButton = screen.getByRole('button', { name: /Run/i })
|
||||
expect(mocks.getRepositoryContext).toHaveBeenCalledWith('/workspace/project')
|
||||
expect(runButton).toBeDisabled()
|
||||
|
||||
await act(async () => { resolveContext(okRepositoryContext()) })
|
||||
expect(await screen.findByRole('button', { name: 'Location: project / main' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(runButton).not.toBeDisabled())
|
||||
|
||||
fireEvent.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createSession).toHaveBeenCalledWith({
|
||||
workDir: '/workspace/project',
|
||||
repository: { branch: 'main', worktree: false },
|
||||
permissionMode: 'default',
|
||||
})
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith('phone-session', expect.objectContaining({ content: 'draft question' }))
|
||||
})
|
||||
})
|
||||
|
||||
it('uses compact composer controls on phone-sized H5 browsers', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isComposerReferenceVisible, isComposerSlashCommandVisible } from '@/lib/composerCapabilityVisibility'
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useDismissable } from '@/hooks/useDismissable'
|
||||
import { ArrowUp, Cpu, Plus, ShieldCheck } from 'lucide-react'
|
||||
import { BrandSeal } from '@/components/composite/BrandSeal'
|
||||
@@ -127,14 +127,11 @@ function resolveCreateSessionErrorMessage(error: unknown, t: Translate): string
|
||||
const EMPTY_COMPOSER_REFERENCES: ComposerReferenceCandidate[] = []
|
||||
|
||||
type EmptySessionProps = {
|
||||
/**
|
||||
* The phone's home page puts its session list where the hero would be, with
|
||||
* this composer docked under it, so a new task starts from the list.
|
||||
*/
|
||||
mobileHome?: ReactNode
|
||||
/** The folder the task starts in; the phone's new-task sheet suggests one. */
|
||||
initialWorkDir?: string
|
||||
}
|
||||
|
||||
export function EmptySession({ mobileHome }: EmptySessionProps = {}) {
|
||||
export function EmptySession({ initialWorkDir = '' }: EmptySessionProps = {}) {
|
||||
const t = useTranslation()
|
||||
const [input, setInput] = useState('')
|
||||
const [mentions, setMentions] = useState<ComposerMention[]>([])
|
||||
@@ -142,10 +139,11 @@ export function EmptySession({ mobileHome }: EmptySessionProps = {}) {
|
||||
const [referenceOptionId, setReferenceOptionId] = useState<string | undefined>()
|
||||
const [referenceState, setReferenceState] = useState<{ context: string, items: ComposerReferenceCandidate[], loading: boolean, error: boolean } | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [workDir, setWorkDir] = useState('')
|
||||
const [workDir, setWorkDir] = useState(initialWorkDir)
|
||||
const [selectedBranch, setSelectedBranch] = useState<string | null>(null)
|
||||
const [useWorktree, setUseWorktree] = useState(false)
|
||||
const [repositoryLaunchReady, setRepositoryLaunchReady] = useState(true)
|
||||
// Same rule as picking a folder: hold Send until its repository is read.
|
||||
const [repositoryLaunchReady, setRepositoryLaunchReady] = useState(!initialWorkDir)
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
|
||||
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
|
||||
@@ -224,12 +222,7 @@ export function EmptySession({ mobileHome }: EmptySessionProps = {}) {
|
||||
// recording bar takes their row.
|
||||
const dictationLive = dictation.phase !== 'idle'
|
||||
|
||||
const showMobileHome = isMobileComposer && mobileHome !== undefined
|
||||
|
||||
useEffect(() => {
|
||||
// The phone's home is a list to read first; focusing the composer would
|
||||
// throw the keyboard over it every time someone comes back to it.
|
||||
if (showMobileHome) return
|
||||
composerRef.current?.focus()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
@@ -812,16 +805,26 @@ export function EmptySession({ mobileHome }: EmptySessionProps = {}) {
|
||||
// The new-session page (「素」, su-12). On desktop the composer sits in the
|
||||
// flow between the hero (bottom-aligned) and the starter row (top-aligned),
|
||||
// which puts it just above the middle of the page. On a phone it stays
|
||||
// docked to the bottom edge, above the keyboard.
|
||||
// docked to the bottom edge, above the keyboard, and the project sits at
|
||||
// the top, where the keyboard cannot cover it and it is read before Send.
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden bg-[var(--color-surface)]">
|
||||
{showMobileHome ? (
|
||||
<div data-testid="mobile-home" className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{mobileHome}
|
||||
{isMobileComposer && (
|
||||
<div data-testid="empty-session-mobile-launch" className="flex shrink-0 justify-center px-4 pt-1">
|
||||
<RepositoryLaunchControls
|
||||
workDir={workDir}
|
||||
onWorkDirChange={handleWorkDirChange}
|
||||
branch={selectedBranch}
|
||||
onBranchChange={setSelectedBranch}
|
||||
useWorktree={useWorktree}
|
||||
onUseWorktreeChange={setUseWorktree}
|
||||
onLaunchReadyChange={setRepositoryLaunchReady}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
<div className={`flex flex-col items-center text-center ${
|
||||
isMobileComposer
|
||||
? 'flex-1 justify-center px-6 pb-[230px] pt-10'
|
||||
? 'min-h-0 flex-1 justify-center overflow-hidden px-6 pb-[230px] pt-6'
|
||||
: 'min-h-0 flex-1 justify-end overflow-hidden px-8 pb-7 pt-8'
|
||||
}`}>
|
||||
<div className={`flex flex-col items-center gap-2.5 ${isMobileComposer ? 'max-w-[300px]' : 'max-w-[600px]'}`}>
|
||||
@@ -840,14 +843,11 @@ export function EmptySession({ mobileHome }: EmptySessionProps = {}) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
data-testid="empty-session-composer-shell"
|
||||
className={`flex justify-center ${
|
||||
showMobileHome
|
||||
? 'relative z-[var(--z-raised)] shrink-0 border-t border-[var(--color-border)] bg-[var(--color-surface)] px-3 pb-2 pt-2'
|
||||
: isMobileComposer
|
||||
isMobileComposer
|
||||
? 'absolute bottom-0 left-0 right-0 z-[var(--z-nav)] px-3 pb-[calc(env(safe-area-inset-bottom)+10px)]'
|
||||
: 'relative z-[var(--z-raised)] shrink-0 px-8'
|
||||
}`}
|
||||
@@ -960,7 +960,7 @@ export function EmptySession({ mobileHome }: EmptySessionProps = {}) {
|
||||
// `min-w-0`: see ChatInput — an unbreakable long run (URL,
|
||||
// hash) otherwise grows this flex item past the panel.
|
||||
className="flex-1 min-w-0"
|
||||
editorClassName={`chat-reading-text ${showMobileHome ? 'min-h-[44px]' : 'min-h-[72px]'} overflow-y-auto px-2.5 pb-1 pt-2 text-[var(--color-text-primary)] ${
|
||||
editorClassName={`chat-reading-text min-h-[72px] overflow-y-auto px-2.5 pb-1 pt-2 text-[var(--color-text-primary)] ${
|
||||
isMobileComposer ? 'max-h-[132px]' : 'max-h-[200px]'
|
||||
}`}
|
||||
aria={{
|
||||
@@ -1087,19 +1087,6 @@ export function EmptySession({ mobileHome }: EmptySessionProps = {}) {
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{isMobileComposer && (
|
||||
<RepositoryLaunchControls
|
||||
workDir={workDir}
|
||||
onWorkDirChange={handleWorkDirChange}
|
||||
branch={selectedBranch}
|
||||
onBranchChange={setSelectedBranch}
|
||||
useWorktree={useWorktree}
|
||||
onUseWorktreeChange={setUseWorktree}
|
||||
onLaunchReadyChange={setRepositoryLaunchReady}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2373,8 +2373,15 @@ body.sidebar-resizing * {
|
||||
.animate-overlay-in-top { animation: overlay-in-from-top 150ms ease-out; }
|
||||
.animate-overlay-in-bottom { animation: overlay-in-from-bottom 150ms ease-out; }
|
||||
.animate-overlay-in-right { animation: overlay-in-from-right 200ms cubic-bezier(0.22, 1, 0.36, 1); }
|
||||
/* A full-height sheet rising from the bottom edge (the phone's new task). */
|
||||
@keyframes sheet-rise {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
.animate-sheet-rise { animation: sheet-rise 280ms cubic-bezier(0.22, 1, 0.36, 1); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-sheet-rise,
|
||||
.animate-overlay-in,
|
||||
.animate-overlay-in-top,
|
||||
.animate-overlay-in-bottom,
|
||||
|
||||
Reference in New Issue
Block a user