From a534e52c8cd039efb64813cc91045866226cef2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 6 Oct 2026 04:08:00 +0800 Subject: [PATCH] feat(desktop): start H5 phone tasks from a new-task sheet 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. --- .../components/mobile/MobileNewTaskSheet.tsx | 100 ++++++++++++++ .../mobile/MobileSessionBrowser.tsx | 31 +++-- .../components/mobile/MobileShell.test.tsx | 128 ++++++++++++++++-- desktop/src/components/mobile/MobileShell.tsx | 47 +++++-- .../src/components/mobile/mobileNavigation.ts | 15 +- .../components/mobile/mobileNewTask.test.ts | 52 +++++++ .../src/components/mobile/mobileNewTask.ts | 31 +++++ desktop/src/i18n/locales/en.ts | 4 +- desktop/src/i18n/locales/jp.ts | 4 +- desktop/src/i18n/locales/kr.ts | 4 +- desktop/src/i18n/locales/zh-TW.ts | 4 +- desktop/src/i18n/locales/zh.ts | 4 +- desktop/src/pages/EmptySession.test.tsx | 54 ++++++-- desktop/src/pages/EmptySession.tsx | 63 ++++----- desktop/src/theme/globals.css | 7 + 15 files changed, 459 insertions(+), 89 deletions(-) create mode 100644 desktop/src/components/mobile/MobileNewTaskSheet.tsx create mode 100644 desktop/src/components/mobile/mobileNewTask.test.ts create mode 100644 desktop/src/components/mobile/mobileNewTask.ts diff --git a/desktop/src/components/mobile/MobileNewTaskSheet.tsx b/desktop/src/components/mobile/MobileNewTaskSheet.tsx new file mode 100644 index 00000000..d1d38244 --- /dev/null +++ b/desktop/src/components/mobile/MobileNewTaskSheet.tsx @@ -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) => { + 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) => { + const drag = dragRef.current + if (!drag || drag.pointerId !== event.pointerId) return + setDragY(Math.max(0, event.clientY - drag.startY)) + } + + const handlePointerEnd = (event: ReactPointerEvent) => { + 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 ( +
+ diff --git a/desktop/src/components/mobile/mobileNavigation.ts b/desktop/src/components/mobile/mobileNavigation.ts index 0cf8afe4..adc3947d 100644 --- a/desktop/src/components/mobile/mobileNavigation.ts +++ b/desktop/src/components/mobile/mobileNavigation.ts @@ -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() } } diff --git a/desktop/src/components/mobile/mobileNewTask.test.ts b/desktop/src/components/mobile/mobileNewTask.test.ts new file mode 100644 index 00000000..eb7dd762 --- /dev/null +++ b/desktop/src/components/mobile/mobileNewTask.test.ts @@ -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 { + 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('') + }) +}) diff --git a/desktop/src/components/mobile/mobileNewTask.ts b/desktop/src/components/mobile/mobileNewTask.ts new file mode 100644 index 00000000..ff08841d --- /dev/null +++ b/desktop/src/components/mobile/mobileNewTask.ts @@ -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) : '' +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index cbca2ca1..90689db2 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 3291a5c9..1750abad 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -4274,9 +4274,11 @@ export const jp: Record = { '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': "実行中", diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index e30da9f1..2632c59a 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -4276,9 +4276,11 @@ export const kr: Record = { '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': "실행 중", diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 7452e89f..a1a2bb8f 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -4273,9 +4273,11 @@ export const zh: Record = { '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': "執行中", diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 9c808b61..e18b61f6 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -4272,9 +4272,11 @@ export const zh: Record = { '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': "运行中", diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index 032e1f23..cd125001 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -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(session list
} />) + render() - 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((resolve) => { + resolveContext = resolve + })) - render(session list} />) + render() + 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 () => { diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index d26cd480..547c9a40 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -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([]) @@ -142,10 +139,11 @@ export function EmptySession({ mobileHome }: EmptySessionProps = {}) { const [referenceOptionId, setReferenceOptionId] = useState() 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(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([]) 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.
- {showMobileHome ? ( -
- {mobileHome} + {isMobileComposer && ( +
+
- ) : ( + )}
@@ -840,14 +843,11 @@ export function EmptySession({ mobileHome }: EmptySessionProps = {}) {

- )}
- - {isMobileComposer && ( - - )}
diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index 3fad4160..e357fb02 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -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,