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:
程序员阿江-Relakkes
2026-10-06 04:09:05 +08:00
committed by GitHub
parent 4fb4784b81
commit 50f784114b
15 changed files with 459 additions and 89 deletions
@@ -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' }],
+39 -8
View File
@@ -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) : ''
}
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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': "実行中",
+3 -1
View File
@@ -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': "실행 중",
+3 -1
View File
@@ -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': "執行中",
+3 -1
View File
@@ -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': "运行中",
+39 -15
View File
@@ -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 () => {
+25 -38
View File
@@ -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>
+7
View File
@@ -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,