feat(desktop): rebuild the H5 phone and tablet shell around touch (#1460)

Replace the drawer-based mobile layout with a single navigation stack. Home
is the session list grouped by status (waiting for you, working, then by
day) with the new-task composer docked under it; a session, Settings and
subagent or team-member pages push on top, and the system back gesture
moves up one level. Touch tablets keep the list beside the page.

- Composer: one row (+, model, send); permission mode, context usage and
  the capability menu move into a + bottom sheet.
- Approvals: a waiting permission, question or plan takes the composer's
  place; the transcript keeps a one-line marker. Denying can carry a reason.
- Messages: press and hold for copy, select text, edit and branch instead
  of an always-visible icon row.
- Tool runs fold into one card per run and open the full timeline in a
  sheet; the turn change card opens a file's diff full screen.
- Activity: a pill in the session bar opens the activity sheet instead of
  auto-opening over the chat.
- Settings: one grouped list with the connection, provider, phone-editable
  preferences and the computer-only settings shown read-only.
- Server: GET /api/sessions/live-status reports every running or waiting
  session so the list can sort sessions the phone never opened.
This commit is contained in:
程序员阿江-Relakkes
2026-10-06 03:18:30 +08:00
committed by GitHub
parent 8ddf67390d
commit 4fb4784b81
78 changed files with 4961 additions and 1107 deletions
+9
View File
@@ -30,6 +30,11 @@ export type SessionChatStatusResponse = {
state: 'idle' | 'thinking' | 'compacting' | 'tool_executing'
activityState: PetSessionRuntimeStatus
}
/** One entry per session that is working or waiting right now; idle sessions are absent. */
export type LiveSessionActivity = {
id: string
activityState: 'running' | 'waiting'
}
type MessagesResponse = {
messages: MessageEntry[]
taskNotifications?: AgentTaskNotification[]
@@ -525,6 +530,10 @@ export const sessionsApi = {
return api.get<SessionListItem>(`/api/sessions/${sessionId}/summary`, options)
},
getLiveStatus(signal?: AbortSignal) {
return api.get<{ sessions: LiveSessionActivity[] }>('/api/sessions/live-status', { signal })
},
getChatStatus(sessionId: string, signal?: AbortSignal) {
return api.get<SessionChatStatusResponse>(`/api/sessions/${sessionId}/chat/status`, { signal })
},
@@ -716,6 +716,19 @@ describe('SessionActivityPanel', () => {
expect(onClose).not.toHaveBeenCalled()
})
it('opens as a bottom sheet on the phone, which a tap inside does not close', () => {
const onClose = vi.fn()
render(<SessionActivityPanel model={model()} open onClose={onClose} onOpenSubagent={vi.fn()} placement="sheet" />)
const sheet = screen.getByTestId('session-activity-sheet')
expect(screen.queryByTestId('session-activity-panel')).not.toBeInTheDocument()
fireEvent.pointerDown(sheet)
expect(onClose).not.toHaveBeenCalled()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('renders as a rail without closing on outside pointerdown', () => {
const onClose = vi.fn()
render(
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronRight, Circle, FileText, LoaderCircle, Square, Terminal, Users, X, Zap } from 'lucide-react'
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet'
import { IconButton } from '@/components/ui/IconButton'
import { Progress } from '@/components/ui/Progress'
import { useDismissable } from '@/hooks/useDismissable'
@@ -22,7 +23,11 @@ export type OpenSubagentPayload = {
teamStartedAt?: number
}
type SessionActivityPanelPlacement = 'overlay' | 'rail'
/**
* `overlay` floats over the session's top right, `rail` docks beside it, and
* `sheet` is the phone's bottom sheet, opened from the top bar's activity pill.
*/
type SessionActivityPanelPlacement = 'overlay' | 'rail' | 'sheet'
type TranslationFn = ReturnType<typeof useTranslation>
@@ -656,7 +661,8 @@ export function SessionActivityPanel({
)
useDismissable({
open,
// A sheet closes itself (scrim, close button, Escape).
open: open && placement !== 'sheet',
refs: [panelRef],
onDismiss: onClose,
isExempt: isDismissExempt,
@@ -676,6 +682,98 @@ export function SessionActivityPanel({
}
}, [model.sections.backgroundTasks.rows, open, selectedBackgroundTaskId])
const sections = visibleSections.map((section, index) => {
const sectionTitle = getSectionTitle(section.id, t)
const taskProgress = section.id === 'tasks' ? getTaskProgress(section.rows) : null
return (
<section
key={section.id}
aria-label={sectionTitle}
className={index > 0 ? 'border-t border-[var(--color-border)] pt-3' : undefined}
>
<div className="mb-1.5 flex items-center justify-between gap-2 px-2">
<div className="flex min-w-0 items-center gap-2">
<h3 className="text-[12px] font-semibold text-[var(--color-text-tertiary)]">
{sectionTitle}
</h3>
{section.rows.length > 0 ? (
<Badge tone="neutral" size="sm" pill={false}>{section.rows.length}</Badge>
) : null}
</div>
{taskProgress ? (
<span className="flex shrink-0 items-center gap-2 text-[12px] tabular-nums text-[var(--color-text-tertiary)]">
{/* `Progress` is `w-full`; the wrapper is what makes it the
52px rail the design calls for, since the two width
utilities would otherwise resolve by stylesheet order. */}
<span className="inline-flex w-[52px] shrink-0">
{/* Named for what it measures, not the section: reusing the
section title made screen readers announce "任务, 50%". */}
<Progress
size="xs"
tone="success"
value={taskProgress.percent}
label={t('session.activity.tasksProgress', {
completed: taskProgress.completed,
total: taskProgress.total,
})}
/>
</span>
{taskProgress.completed}/{taskProgress.total}
</span>
) : null}
{section.id === 'backgroundTasks' && finishedBackgroundTaskKeys.length > 0 && onClearFinishedBackgroundTasks ? (
<Button
variant="ghost"
size="xs"
onClick={() => onClearFinishedBackgroundTasks(finishedBackgroundTaskKeys)}
>
{t('session.activity.clearFinished')}
</Button>
) : null}
</div>
<div className={getSectionRowsClassName(section.id, section.rows.length)}>
{section.rows.map((row) => (
<div key={row.id}>
<ActivityRowView
row={row}
sessionId={model.sessionId}
onOpenSubagent={onOpenSubagent}
onOpenMember={onOpenMember}
onStopBackgroundTask={onStopBackgroundTask}
stoppingBackgroundTask={Boolean(row.taskId && stoppingBackgroundTaskIds?.[row.taskId])}
onOpenBackgroundTask={(backgroundRow) => {
setSelectedBackgroundTaskId((current) => (
current === backgroundRow.id ? null : backgroundRow.id
))
}}
selected={section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id}
/>
{section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id ? (
<BackgroundTaskDetail row={row} />
) : null}
</div>
))}
</div>
</section>
)
})
if (placement === 'sheet') {
return (
<MobileBottomSheet
open={open}
onClose={onClose}
title={t('session.activity.title')}
closeLabel={t('session.activity.close')}
testId="session-activity-sheet"
contentClassName="space-y-3 px-3 pb-4 pt-3"
>
{sections}
</MobileBottomSheet>
)
}
if (!open) return null
// Both placements are out-of-flow overlays pinned to the top right of the
// session area. `rail` used to be an in-flow flex sibling, which is why the
@@ -730,82 +828,7 @@ export function SessionActivityPanel({
data-testid="session-activity-scroll"
className={`min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-4 pb-4 pt-0.5 ${ACTIVITY_SCROLLBAR_CLASS}`}
>
{visibleSections.map((section, index) => {
const sectionTitle = getSectionTitle(section.id, t)
const taskProgress = section.id === 'tasks' ? getTaskProgress(section.rows) : null
return (
<section
key={section.id}
aria-label={sectionTitle}
className={index > 0 ? 'border-t border-[var(--color-border)] pt-3' : undefined}
>
<div className="mb-1.5 flex items-center justify-between gap-2 px-2">
<div className="flex min-w-0 items-center gap-2">
<h3 className="text-[12px] font-semibold text-[var(--color-text-tertiary)]">
{sectionTitle}
</h3>
{section.rows.length > 0 ? (
<Badge tone="neutral" size="sm" pill={false}>{section.rows.length}</Badge>
) : null}
</div>
{taskProgress ? (
<span className="flex shrink-0 items-center gap-2 text-[12px] tabular-nums text-[var(--color-text-tertiary)]">
{/* `Progress` is `w-full`; the wrapper is what makes it the
52px rail the design calls for, since the two width
utilities would otherwise resolve by stylesheet order. */}
<span className="inline-flex w-[52px] shrink-0">
{/* Named for what it measures, not the section: reusing the
section title made screen readers announce "任务, 50%". */}
<Progress
size="xs"
tone="success"
value={taskProgress.percent}
label={t('session.activity.tasksProgress', {
completed: taskProgress.completed,
total: taskProgress.total,
})}
/>
</span>
{taskProgress.completed}/{taskProgress.total}
</span>
) : null}
{section.id === 'backgroundTasks' && finishedBackgroundTaskKeys.length > 0 && onClearFinishedBackgroundTasks ? (
<Button
variant="ghost"
size="xs"
onClick={() => onClearFinishedBackgroundTasks(finishedBackgroundTaskKeys)}
>
{t('session.activity.clearFinished')}
</Button>
) : null}
</div>
<div className={getSectionRowsClassName(section.id, section.rows.length)}>
{section.rows.map((row) => (
<div key={row.id}>
<ActivityRowView
row={row}
sessionId={model.sessionId}
onOpenSubagent={onOpenSubagent}
onOpenMember={onOpenMember}
onStopBackgroundTask={onStopBackgroundTask}
stoppingBackgroundTask={Boolean(row.taskId && stoppingBackgroundTaskIds?.[row.taskId])}
onOpenBackgroundTask={(backgroundRow) => {
setSelectedBackgroundTaskId((current) => (
current === backgroundRow.id ? null : backgroundRow.id
))
}}
selected={section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id}
/>
{section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id ? (
<BackgroundTaskDetail row={row} />
) : null}
</div>
))}
</div>
</section>
)
})}
{sections}
</div>
</div>
)
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { ActivityGroup } from './ActivityGroup'
import { buildActivitySegments, formatActivitySummary, type ActivityStep } from './activityGroupModel'
import { useSettingsStore } from '../../stores/settingsStore'
@@ -419,3 +420,65 @@ describe('buildActivitySegments', () => {
expect(formatActivitySummary(buildActivitySegments(steps, t), t)).toBe(`${t('toolGroup.searchedOne')}, ${t('toolGroup.readMany', { count: 2 })}`)
})
})
describe('ActivityGroup on a phone', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
const read = toolCall({ id: 'use-read', toolUseId: 'read-1', toolName: 'Read', input: { file_path: '/repo/src/todo.ts' }, timestamp: 1_000 })
const bash = toolCall({ id: 'use-bash', toolUseId: 'bash-1', toolName: 'Bash', input: { command: 'bun test' }, timestamp: 2_000 })
const steps: ActivityStep[] = [{ kind: 'tool', toolCall: read }, { kind: 'tool', toolCall: bash }]
it('folds a finished run into one card and opens the whole timeline in a sheet', () => {
render(
<ActivityGroup
presentation="sheet"
steps={steps}
resultMap={resultsOf([
toolResult({ id: 'r1', toolUseId: 'read-1', timestamp: 1_500 }),
toolResult({ id: 'r2', toolUseId: 'bash-1', timestamp: 3_000 }),
])}
childToolCallsByParent={new Map()}
/>,
)
const card = screen.getByTestId('activity-group')
expect(card).toHaveAttribute('data-presentation', 'sheet')
expect(card).toHaveTextContent(formatActivitySummary(buildActivitySegments(steps, t), t))
// Nothing unfolds in place: the transcript keeps its place on a small screen.
expect(within(card).queryByText('bun test')).toBeNull()
expect(screen.queryByTestId('activity-live-step')).toBeNull()
fireEvent.click(within(card).getByRole('button'))
const sheet = screen.getByTestId('activity-timeline-sheet')
expect(within(sheet).getByText('bun test')).toBeTruthy()
})
it('shows the step under way while the run is live', () => {
render(
<ActivityGroup
presentation="sheet"
steps={steps}
resultMap={resultsOf([toolResult({ id: 'r1', toolUseId: 'read-1', timestamp: 1_500 })])}
childToolCallsByParent={new Map()}
isLive
/>,
)
expect(screen.getByTestId('activity-group')).toHaveAttribute('data-running', 'true')
expect(within(screen.getByTestId('activity-live-step')).getByText('bun test')).toBeTruthy()
})
it('gives even a single call the card, so every run reads the same', () => {
render(
<ActivityGroup
presentation="sheet"
steps={[{ kind: 'tool', toolCall: bash }]}
resultMap={resultsOf([toolResult({ id: 'r2', toolUseId: 'bash-1', timestamp: 3_000 })])}
childToolCallsByParent={new Map()}
/>,
)
expect(screen.getByTestId('activity-group')).toHaveAttribute('data-presentation', 'sheet')
})
})
+103 -2
View File
@@ -1,7 +1,8 @@
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
import { getDisclosure, setDisclosure } from '../../lib/disclosureMemory'
import { toolResultImagesFor, type ToolResultImageExtraction } from '@/lib/toolResultContent'
import { ChevronRight, CirclePause, CircleX, LoaderCircle } from 'lucide-react'
import { Check, ChevronRight, CirclePause, CircleX, LoaderCircle } from 'lucide-react'
import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet'
import { ToolCallBlock, formatDuration } from './ToolCallBlock'
import { ThinkingBlock } from './ThinkingBlock'
import { ToolResultImages } from './ToolResultImages'
@@ -44,6 +45,13 @@ type Props = {
revealToolUseId?: string
/** Calls whose permission prompt is waiting on the user. */
awaitingToolUseIds?: ReadonlySet<string>
/**
* `inline` opens the run in place. `sheet` (the phone) keeps every run one
* card: the counted summary, plus the step under way while it runs; a tap
* opens the whole timeline in a sheet. A run unfolding in place on a phone
* pushed the conversation off the screen a step at a time.
*/
presentation?: 'inline' | 'sheet'
}
const MAX_HEADER_ICONS = 3
@@ -98,8 +106,10 @@ export const ActivityGroup = memo(function ActivityGroup({
disclosureKey,
revealToolUseId,
awaitingToolUseIds,
presentation = 'inline',
}: Props) {
const t = useTranslation()
const [sheetOpen, setSheetOpen] = useState(false)
/** null = follow the run's own state; set = the reader decided. */
const [pinnedCollapsedLocal, setPinnedCollapsedLocal] = useState<boolean | null>(null)
const pinnedCollapsed = disclosureKey
@@ -146,7 +156,7 @@ export const ActivityGroup = memo(function ActivityGroup({
}
const soleToolCall = steps.length === 1 && steps[0]?.kind === 'tool' ? steps[0].toolCall : null
if (soleToolCall) {
if (soleToolCall && presentation === 'inline') {
return (
<div>
<div
@@ -171,6 +181,97 @@ export const ActivityGroup = memo(function ActivityGroup({
.filter((segment, index) => segments.findIndex((other) => other.icon === segment.icon) === index)
.slice(0, MAX_HEADER_ICONS)
if (presentation === 'sheet') {
const lastToolStep = [...steps].reverse().find((step) => step.kind === 'tool')
const liveToolCall = showsProgress && lastToolStep?.kind === 'tool' ? lastToolStep.toolCall : null
const timeline = (
<ToolTimeline>
{steps.map((step) => step.kind === 'thinking' ? (
<TimelineThinking
key={step.message.id}
content={step.message.content}
isActive={step.message.id === activeThinkingId}
/>
) : (
<ActivityToolRow key={step.toolCall.id} toolCall={step.toolCall} {...rowProps} />
))}
</ToolTimeline>
)
return (
<div
data-testid="activity-group"
data-presentation="sheet"
data-running={isRunning ? 'true' : 'false'}
className={`overflow-hidden rounded-[var(--radius-lg)] border bg-[var(--color-surface-container-lowest)] ${
showsProgress ? 'border-[var(--color-info)]' : 'border-[var(--color-border)]'
}`}
>
<button
type="button"
data-chat-disclosure="true"
aria-haspopup="dialog"
onClick={() => setSheetOpen(true)}
className="flex min-h-11 w-full items-center gap-2 px-3 py-2 text-left text-[13px] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span
aria-hidden="true"
className={`flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full border ${
awaitingCount > 0
? 'border-[var(--color-warning)] bg-[var(--color-warning-container)] text-[var(--color-on-warning-container)]'
: showsProgress
? 'border-[var(--color-info)] bg-[var(--color-info-container)] text-[var(--color-on-info-container)]'
: failedCount > 0
? 'border-[var(--color-error-container)] bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
: 'border-[var(--color-success)] bg-[var(--color-success-container)] text-[var(--color-on-success-container)]'
}`}
>
{awaitingCount > 0 ? <CirclePause size={12} strokeWidth={2} />
: showsProgress ? <LoaderCircle size={12} strokeWidth={2} className="animate-spin" />
: failedCount > 0 ? <CircleX size={12} strokeWidth={2} />
: <Check size={12} strokeWidth={2} />}
</span>
<span data-activity-summary="" className="min-w-0 flex-1 truncate font-medium text-[var(--color-text-primary)]">
{summaryText}
</span>
{failedCount > 0 && (
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[12px] text-[var(--color-error)]">
{t('toolGroup.failedCount', { count: failedCount })}
</span>
)}
{awaitingCount > 0 ? (
<span className="shrink-0 whitespace-nowrap text-[12px] font-medium text-[var(--color-on-warning-container)]">
{t('permission.awaitingApproval')}
</span>
) : showsProgress ? (
<span className="shrink-0 whitespace-nowrap text-[12px] text-[var(--color-info)]">{t('agentStatus.running')}</span>
) : durationLabel ? (
<span className="shrink-0 whitespace-nowrap font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]">{durationLabel}</span>
) : null}
<ChevronRight size={16} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
{liveToolCall ? (
<div data-testid="activity-live-step" className="border-t border-[var(--color-border)] px-2 pb-1 pt-1">
<ToolTimeline>
<ActivityToolRow toolCall={liveToolCall} {...rowProps} />
</ToolTimeline>
</div>
) : null}
{!liveToolCall ? <CollapsedRunImages toolCalls={toolCalls} resultMap={resultMap} /> : null}
<MobileBottomSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
title={summaryText}
closeLabel={t('common.close')}
testId="activity-timeline-sheet"
tall
contentClassName="px-3 py-2"
>
{timeline}
</MobileBottomSheet>
</div>
)
}
return (
<div>
<div
@@ -9,6 +9,7 @@ import { useTranslation } from '../../i18n'
import { ArrowRight, Check, CircleCheck, Info, MessageCircleQuestion, MessagesSquare, Send } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { PendingDecisionMarker } from './PendingDecisionMarker'
import {
ASK_USER_QUESTION_CLARIFY_WITH_QUESTIONS_PREFIX,
ASK_USER_QUESTION_EXPIRED_ANSWER_PREFIX,
@@ -37,6 +38,12 @@ type Props = {
* Computed by buildRenderModel, the only place that sees the whole list.
*/
supersededByUserMessage?: boolean
/**
* While the question waits on a live request, draw one marker line instead
* of the card: the phone answers it from the approval bar, and two live
* copies of the card would each hold half an answer.
*/
markerWhenPending?: boolean
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -100,6 +107,7 @@ export function AskUserQuestion({
input,
result,
supersededByUserMessage,
markerWhenPending = false,
}: Props) {
const { respondToPermission } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
@@ -212,6 +220,9 @@ export function AskUserQuestion({
// direction. With the early return above the useMemo calls, that transition threw
// "Rendered fewer/more hooks than expected" and took the whole message list down.
if (questions.length === 0) return null
if (markerWhenPending && pendingRequest) {
return <PendingDecisionMarker title={t('mobile.approval.questionTitle')} />
}
const safeActiveTab = Math.min(activeTab, questions.length - 1)
const activeQuestion = questions[safeActiveTab]
@@ -6,6 +6,7 @@ import { buildOpenWithMenuItemsForHref } from '../../lib/openWithMenuItems'
import { fileRefFromElement } from '../../lib/markdownAutolink'
import type { OpenWithItem } from '../../lib/openWithItems'
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
import { useMessageActionMenu } from './useMessageActionMenu'
import { TurnCompletionStamp } from './TurnCompletionStamp'
import type { TurnCompletion } from '../../lib/turnCompletion'
import { ImageGalleryModal } from './ImageGalleryModal'
@@ -165,25 +166,33 @@ export const AssistantMessage = memo(function AssistantMessage({
[t, workDir],
)
const showTurnCompletion = !isStreaming && Boolean(turnCompletion)
// On a phone any finished reply can be held for copy and select; branching
// stays with the reply that closes a turn, as on the desktop bar.
const actionMenu = useMessageActionMenu({
copyText: isStreaming ? undefined : content,
branchAction: showTurnCompletion ? branchAction : undefined,
})
if (!content.trim()) return null
const documentLayout = shouldUseDocumentLayout(content)
const showTurnCompletion = !isStreaming && Boolean(turnCompletion)
return (
<div className="flex justify-start">
<div
data-message-shell="assistant"
data-layout={documentLayout ? 'document' : 'bubble'}
{...actionMenu.pressProps}
// Always the full column. A reply that hugs its text turns every short
// answer into a differently-shaped block, so a scrolled transcript reads
// as a ragged pile; one width makes the replies a single column the eye
// can run down. The user bubble stays hugged — that asymmetry is what
// says which side is speaking, so it does not need width to say it too.
className="group flex w-full min-w-0 max-w-full flex-col items-start"
className={`group flex w-full min-w-0 max-w-full flex-col items-start ${actionMenu.pressClassName}`}
>
<div
onContextMenu={sessionId ? handleContextMenu : undefined}
onContextMenu={sessionId && !actionMenu.enabled ? handleContextMenu : undefined}
// No card. Left-aligned, full-column prose against the page is already
// unmistakably the reply — the hugged, tinted bubble on the right is
// what says who is speaking (see the note above), so a border here
@@ -267,14 +276,15 @@ export const AssistantMessage = memo(function AssistantMessage({
*/}
{showTurnCompletion && (
<MessageActionBar
copyText={content}
copyText={actionMenu.enabled ? undefined : content}
copyLabel={t('chat.copyReply')}
branchAction={branchAction}
branchAction={actionMenu.enabled ? undefined : branchAction}
align="start"
alwaysVisible
metadata={<TurnCompletionStamp completion={turnCompletion!} />}
/>
)}
{actionMenu.sheet}
</div>
</div>
)
@@ -2808,6 +2808,80 @@ describe('ChatInput file mentions', () => {
expect(fileSearchMenu).not.toHaveTextContent('Navigate')
})
it('moves the low-frequency controls off the phone toolbar into the + sheet', async () => {
viewportMocks.isMobile = true
render(<ChatInput />)
await waitFor(() => {
expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId)
})
// Context usage stays mounted to keep tracking, but takes no toolbar slot.
expect(screen.getByTestId('context-usage-indicator')).not.toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'Open composer tools' }))
const sheet = screen.getByTestId('mobile-composer-sheet')
expect(within(sheet).getByRole('button', { name: /Execution Permissions/ })).toHaveTextContent('Ask permissions')
// The phone sheet must not grab focus: that would throw the keyboard up
// over the list that was just opened.
expect(within(sheet).getByRole('combobox')).not.toHaveFocus()
fireEvent.click(within(sheet).getByRole('button', { name: /^Context/ }))
// One sheet at a time: the + sheet closes before the breakdown opens.
expect(screen.queryByTestId('mobile-composer-sheet')).not.toBeInTheDocument()
expect(await screen.findByTestId('context-usage-sheet')).toBeInTheDocument()
})
it('hands the phone composer slot to the approval bar while a request waits, and takes it back', async () => {
viewportMocks.isMobile = true
render(<ChatInput />)
await waitFor(() => {
expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId)
})
const request = { requestId: 'perm-1', toolName: 'Bash', toolUseId: 'toolu-1', input: { command: 'bun run build' } }
act(() => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[sessionId]: { ...state.sessions[sessionId]!, pendingPermission: request, pendingPermissions: { [request.requestId]: request } },
},
}))
})
expect(screen.getByTestId('mobile-approval-dock')).toHaveTextContent('bun run build')
expect(screen.queryByTestId('chat-input-panel')).not.toBeInTheDocument()
act(() => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[sessionId]: { ...state.sessions[sessionId]!, pendingPermission: null, pendingPermissions: {} },
},
}))
})
expect(screen.queryByTestId('mobile-approval-dock')).not.toBeInTheDocument()
expect(screen.getByTestId('chat-input-panel')).toBeInTheDocument()
})
it('leaves the desktop composer alone while a request waits, since the card is in the transcript', async () => {
render(<ChatInput />)
await waitFor(() => {
expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId)
})
const request = { requestId: 'perm-1', toolName: 'Bash', toolUseId: 'toolu-1', input: { command: 'bun run build' } }
act(() => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[sessionId]: { ...state.sessions[sessionId]!, pendingPermission: request, pendingPermissions: { [request.requestId]: request } },
},
}))
})
expect(screen.queryByTestId('mobile-approval-dock')).not.toBeInTheDocument()
expect(screen.getByTestId('chat-input-panel')).toBeInTheDocument()
})
it('keeps the active-session toolbar in flow so multiline caret cannot render behind controls', async () => {
render(<ChatInput />)
+106 -17
View File
@@ -5,13 +5,14 @@ import { getSessionReferences } from '@/lib/composerMentions'
import { normalizeSessionReferences } from '@/lib/sessionReferences'
import { isComposerReferenceVisible, isComposerSlashCommandVisible } from '@/lib/composerCapabilityVisibility'
import { useState, useRef, useEffect, useCallback, useMemo, useId } from 'react'
import { ArrowUp, CornerDownRight, Pencil, Plus, Square, Trash2 } from 'lucide-react'
import { ArrowUp, CornerDownRight, Cpu, Gauge, Pencil, Plus, ShieldCheck, Square, Trash2 } from 'lucide-react'
import { useDismissable } from '@/hooks/useDismissable'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { useTranslation } from '../../i18n'
import {
hasPendingAskUserQuestion,
listPendingPermissions,
useChatStore,
type RepositoryLaunchDraftState,
} from '../../stores/chatStore'
@@ -30,7 +31,8 @@ import {
} from '../../stores/workspaceChatContextStore'
import { sessionsApi, type SessionGitInfo } from '../../api/sessions'
import { agentsApi } from '../../api/agents'
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
import { PermissionModeSelector, type PermissionModeSelectorHandle } from '../controls/PermissionModeSelector'
import { PERMISSION_MODE_LABEL_KEYS, useResolvedPermissionMode } from '../controls/permissionModeState'
import { ModelSelector, type ModelSelectorHandle } from '../controls/ModelSelector'
import type { AttachmentRef } from '../../types/chat'
import { AttachmentGallery } from './AttachmentGallery'
@@ -40,12 +42,14 @@ import { RepositoryLaunchControls } from '@/components/chat/RepositoryLaunchCont
import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from './ComposerReferenceMenu'
import { ComposerReferenceDetail } from './ComposerReferenceDetail'
import { ComposerCapabilityMenu } from './ComposerCapabilityMenu'
import { MobileComposerSheet, type MobileComposerSetting } from './MobileComposerSheet'
import { MobileApprovalDock } from './MobileApprovalDock'
import { useCapabilityMenu } from './useCapabilityMenu'
import { composerReferencesApi, mentionProviderId } from '@/api/composerReferences'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel'
import { getSlashCommandOptionId, SlashCommandMenu } from './SlashCommandMenu'
import { ContextUsageIndicator } from './ContextUsageIndicator'
import { ContextUsageIndicator, type ContextUsageIndicatorHandle } from './ContextUsageIndicator'
import {
appendAgentSlashCommands,
buildAgentSlashCommands,
@@ -74,6 +78,7 @@ import {
findMentionRanges,
insertMentionIntoText,
type ComposerMention,
type NewComposerMention,
} from '../../lib/composerMentions'
import type { PermissionMode } from '../../types/settings'
import { getSessionWorkspaceState, getSessionSeedWorkDir } from '../../lib/sessionWorkspace'
@@ -181,6 +186,8 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
const panelRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const modelSelectorRef = useRef<ModelSelectorHandle>(null)
const permissionSelectorRef = useRef<PermissionModeSelectorHandle>(null)
const contextUsageRef = useRef<ContextUsageIndicatorHandle>(null)
const plusMenuRef = useRef<HTMLDivElement>(null)
const slashMenuRef = useRef<HTMLDivElement>(null)
const fileSearchRef = useRef<ComposerReferenceMenuHandle>(null)
@@ -300,6 +307,7 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
const isMemberSession = !!memberInfo || activeTabType === 'subagent'
const isActive = chatState !== 'idle'
const resolvedPermissionMode = useResolvedPermissionMode(activeTabId ?? undefined)
const hasRunningSubagents = hasRunningSubagentTasks(sessionState?.backgroundAgentTasks)
// Approved team processes are tracked by their plan, not background-agent
// notifications. Keep Stop available after the review card is dismissed.
@@ -633,7 +641,9 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
}, [activeSession?.workDir, activeSession?.projectRoot, activeSession?.workspaceState, activeTabId, gitInfo?.workDir, showLaunchControls])
useDismissable({
open: plusMenuOpen,
// The phone's + opens a sheet in a portal, which closes itself; an outside
// press listener here would read every tap inside it as "outside".
open: plusMenuOpen && !isMobileComposer,
refs: [plusMenuRef],
onDismiss: () => setPlusMenuOpen(false),
})
@@ -1227,6 +1237,52 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
// The "+" capability menu. The hook owns data loading and navigation
// actions; these handlers are only the composer-local edits (mention badge,
// slash text, prompt seed) plus the surfaces this composer already opens.
const insertSelectedFileMention = (mention: NewComposerMention) => {
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? inputRef.current.length
const inserted = insertMentionIntoText(inputRef.current, mentionsRef.current, cursorPos, cursorPos, mention)
setComposerInput(inserted.text, inserted.mentions)
requestAnimationFrame(() => {
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(inserted.cursorPos)
})
}
// The phone's + sheet carries the controls that left its toolbar. A row
// closes the sheet first and opens the control's own sheet a frame later,
// so the two never stack.
const openFromComposerSheet = (open: () => void) => {
setPlusMenuOpen(false)
requestAnimationFrame(open)
}
const mobileComposerSettings: MobileComposerSetting[] = isMobileComposer && !isMemberSession
? [
{
key: 'permission',
icon: <ShieldCheck size={16} strokeWidth={1.75} aria-hidden="true" />,
label: t('permMode.executionPermissions'),
value: t(PERMISSION_MODE_LABEL_KEYS[resolvedPermissionMode]),
disabled: isActive,
onSelect: () => openFromComposerSheet(() => permissionSelectorRef.current?.open()),
},
...(activeTabId ? [
{
key: 'model',
icon: <Cpu size={16} strokeWidth={1.75} aria-hidden="true" />,
label: t('chat.mobileSheet.model'),
value: runtimeModelLabel,
disabled: isActive,
onSelect: () => openFromComposerSheet(() => modelSelectorRef.current?.open()),
},
{
key: 'context',
icon: <Gauge size={16} strokeWidth={1.75} aria-hidden="true" />,
label: t('contextIndicator.title'),
onSelect: () => openFromComposerSheet(() => contextUsageRef.current?.open()),
},
] : []),
]
: []
const capabilityMenu = useCapabilityMenu({
open: plusMenuOpen && !isMemberSession,
cwd: referenceCwd,
@@ -1279,6 +1335,24 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
? t('teams.memberPlaceholder')
: t('chat.placeholder')
// On a phone a waiting request takes the composer's place (MessageList shows
// a marker where the card was). Not for a member's page or a side chat:
// neither hosts its list with `decisionsInComposer`, so the card stays there.
const approvalInComposer = isMobileComposer && !isMemberSession && !sideChat && !!activeTabId &&
listPendingPermissions(sessionState).length > 0
if (approvalInComposer) {
return (
<div
ref={shellRef}
data-testid="chat-input-shell"
data-session-id={activeTabId}
className="composer-fade bg-[var(--color-surface)] px-3 pb-[calc(env(safe-area-inset-bottom)+10px)] pt-1"
>
<MobileApprovalDock sessionId={activeTabId} />
</div>
)
}
return (
<div
ref={shellRef}
@@ -1619,31 +1693,42 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
<Plus size={isMobileComposer ? 18 : 16} strokeWidth={1.75} aria-hidden="true" />
</button>
{plusMenuOpen && (
{plusMenuOpen && !isMobileComposer && (
<ComposerCapabilityMenu
cwd={referenceCwd}
referencesLoading={referenceCurrent?.loading ?? true}
referencesError={referenceCurrent?.error}
onSelectFile={mention => {
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? inputRef.current.length
const inserted = insertMentionIntoText(inputRef.current, mentionsRef.current, cursorPos, cursorPos, mention)
setComposerInput(inserted.text, inserted.mentions)
requestAnimationFrame(() => {
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(inserted.cursorPos)
})
}}
onSelectFile={insertSelectedFileMention}
id={capabilityMenuId}
sections={capabilityMenu.sections}
onAction={capabilityMenu.onAction}
onClose={() => setPlusMenuOpen(false)}
mobile={isMobileComposer}
/>
)}
{isMobileComposer && (
<MobileComposerSheet
open={plusMenuOpen}
onClose={() => setPlusMenuOpen(false)}
menuId={capabilityMenuId}
settings={mobileComposerSettings}
sections={capabilityMenu.sections}
cwd={referenceCwd}
referencesLoading={referenceCurrent?.loading ?? true}
referencesError={referenceCurrent?.error}
onSelectFile={insertSelectedFileMention}
onAction={capabilityMenu.onAction}
/>
)}
</div>
<div className="shrink-0">
<PermissionModeSelector sessionId={activeTabId ?? undefined} workDir={resolvedWorkDir} compact={useCompactControls} />
<PermissionModeSelector
ref={permissionSelectorRef}
sessionId={activeTabId ?? undefined}
workDir={resolvedWorkDir}
compact={useCompactControls}
trigger={isMobileComposer ? 'elevatedOnly' : 'chip'}
/>
</div>
{showLocationInToolbar && (
@@ -1686,6 +1771,8 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
>
{!isMemberSession && activeTabId && (
<ContextUsageIndicator
ref={contextUsageRef}
hideTrigger={isMobileComposer}
sessionId={activeTabId}
chatState={chatState}
messageCount={messageCount}
@@ -1768,7 +1855,9 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
{!isMemberSession && !showLocationInToolbar && (
{/* On a phone, a session under way names its project in the top bar;
only a session still choosing where to run needs the picker here. */}
{!isMemberSession && !showLocationInToolbar && !(isMobileComposer && messageCount > 0) && (
<div className={useCompactControls ? 'mt-2 flex min-w-0 px-1' : 'mt-3 px-1'}>
{messageCount > 0 ? (
<ProjectContextChip
@@ -20,7 +20,13 @@ type Props = {
onSelectFile?: (mention: NewComposerMention) => void
onAction(action: CapabilityAction): void
onClose(): void
mobile?: boolean
/**
* `popover` floats above the composer's + button. `sheet` lays the same menu
* out inside the phone's bottom sheet: in the flow, 44px rows, and no
* autofocused search, since focusing a field on a phone throws the keyboard
* up over the very list that was just opened.
*/
presentation?: 'popover' | 'sheet'
}
export function getCapabilityMenuOptionId(id: string, index: number): string {
@@ -54,7 +60,8 @@ function RowIcon({ icon, iconColor }: { icon: CapabilityIcon, iconColor?: string
}
/** The + launcher uses the same search, rows and mention selection as @. */
export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoading, referencesError, onSelectFile, onAction, onClose, mobile = false }: Props) {
export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoading, referencesError, onSelectFile, onAction, onClose, presentation = 'popover' }: Props) {
const sheet = presentation === 'sheet'
const t = useTranslation()
const [query, setQuery] = useState('')
const [path, setPath] = useState<string[]>([])
@@ -131,29 +138,33 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi
}
const renderRow = (item: CapabilityMenuItem, index: number) => <ComposerSuggestionRow
key={item.key} id={getCapabilityMenuOptionId(id, index)} label={item.label}
selected={index === activeIndex} icon={<RowIcon icon={item.icon} iconColor={item.iconColor} />}
// A finger has no keyboard cursor to show; the sheet keeps rows plain.
selected={!sheet && index === activeIndex} icon={<RowIcon icon={item.icon} iconColor={item.iconColor} />}
aria-label={item.switch ? `${item.label}: ${t(item.switch.checked ? 'settings.plugins.status.enabled' : 'settings.plugins.status.disabled')}` : undefined}
aria-labelledby={item.switch ? undefined : `${getCapabilityMenuOptionId(id, index)}-label`}
aria-disabled={item.disabled || item.switch?.disabled || undefined}
title={item.disabledReason ?? item.description}
touch={sheet}
onMouseEnter={() => setHighlight(index)} onClick={() => activate(item)}
trailing={item.switch ? <span className="-my-1 shrink-0" onClick={event => event.stopPropagation()}>
<Switch size="sm" checked={item.switch.checked} disabled={item.switch.disabled} label={t('chat.capabilities.computerUseToggle')} labelHidden onChange={() => item.action && onAction(item.action)} />
</span> : item.children ? <ChevronRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-[var(--color-text-tertiary)]" /> : item.key === 'slash-commands' ? <kbd className={COMPOSER_KBD}>/</kbd> : null}
/>
let offset = 0
return <div className={`absolute bottom-full left-0 z-[var(--z-dropdown)] mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)] ${mobile ? 'w-[min(360px,calc(100vw-32px))]' : showReferences ? 'w-[min(480px,calc(100vw-32px))]' : 'w-[min(288px,calc(100vw-32px))]'}`} onMouseDown={event => event.preventDefault()}>
<div className="flex h-10 items-center gap-2 border-b border-[var(--color-border)] px-3">
return <div className={sheet
? 'flex min-w-0 flex-col'
: `absolute bottom-full left-0 z-[var(--z-dropdown)] mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)] ${showReferences ? 'w-[min(480px,calc(100vw-32px))]' : 'w-[min(288px,calc(100vw-32px))]'}`} onMouseDown={sheet ? undefined : event => event.preventDefault()}>
<div className={`flex items-center gap-2 border-b border-[var(--color-border)] px-3 ${sheet ? 'h-12' : 'h-10'}`}>
{drillParent ? <IconButton icon={<ChevronLeft size={14} strokeWidth={1.75} />} label={t('chat.capabilities.back')} size="xs" tone="muted" onClick={goBack} /> : null}
<Search aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<input autoFocus value={query} onChange={event => { setQuery(event.target.value); setHighlight(0) }} onKeyDown={handleKeyDown} onClick={event => event.currentTarget.focus()}
<input autoFocus={!sheet} value={query} onChange={event => { setQuery(event.target.value); setHighlight(0) }} onKeyDown={handleKeyDown} onClick={event => event.currentTarget.focus()}
placeholder={drillParent?.label ?? t('chat.capabilities.searchPlaceholder')} aria-label={t('chat.capabilities.searchPlaceholder')}
role="combobox" aria-expanded="true" aria-controls={listId} aria-activedescendant={activeOptionId}
className="min-w-0 flex-1 bg-transparent text-[13px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" />
className={`min-w-0 flex-1 bg-transparent text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] ${sheet ? 'text-[15px]' : 'text-[13px]'}`} />
</div>
{showReferences ? <ComposerReferenceMenu key={path.join('/')} ref={referenceRef} id={listId} cwd={cwd} filter={query} embedded browseReferences={browseReferences} references={references} actions={actions}
referencesLoading={referencesLoading} referencesError={referencesError} onSelect={selectMention} onActiveChange={setReferenceOptionId} /> :
<div ref={listRef} id={listId} role="listbox" aria-label={t('chat.composerTools')} className="max-h-[min(360px,50vh)] overflow-y-auto p-1">
<div ref={listRef} id={listId} role="listbox" aria-label={t('chat.composerTools')} className={sheet ? 'p-2' : 'max-h-[min(360px,50vh)] overflow-y-auto p-1'}>
{drillParent ? items.map(renderRow) : sections.map(section => {
const start = offset
offset += section.items.length
@@ -8,6 +8,8 @@ type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
icon?: ReactNode
trailing?: ReactNode
selected?: boolean
/** 44px rows for the phone's bottom sheet, where a finger picks the row. */
touch?: boolean
}
/**
@@ -16,11 +18,11 @@ type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
* hover fill doubling as the keyboard highlight.
*/
export const ComposerSuggestionRow = forwardRef<HTMLDivElement, Props>(function ComposerSuggestionRow({
id, label, description, details, icon, trailing, selected = false, className = '', ...rest
id, label, description, details, icon, trailing, selected = false, touch = false, className = '', ...rest
}, ref) {
return <div ref={ref} id={id} role="option" tabIndex={-1} aria-selected={selected}
aria-labelledby={`${id}-label`} aria-describedby={description || details ? `${id}-description` : undefined}
className={`flex min-h-8 min-w-0 cursor-default items-center gap-2.5 rounded-[var(--radius-sm)] px-2 py-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${selected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'} ${className}`} {...rest}>
className={`flex ${touch ? 'min-h-11' : 'min-h-8'} min-w-0 cursor-default items-center gap-2.5 rounded-[var(--radius-sm)] px-2 py-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${selected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'} ${className}`} {...rest}>
{icon}
{details ? <span className="min-w-0 flex-1 space-y-0.5">
<span id={`${id}-label`} title={label} className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">{label}</span>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import {
sessionsApi,
@@ -32,6 +32,16 @@ type Props = {
* and after a replacement runtime confirms it has started.
*/
refreshNonce?: number
/**
* Keep the indicator mounted (it still tracks the session) but draw no
* trigger: the phone composer opens the breakdown from its + sheet instead
* of spending a 44px toolbar slot on it.
*/
hideTrigger?: boolean
}
export type ContextUsageIndicatorHandle = {
open: () => void
}
const ACTIVE_REFRESH_MS = 30_000
@@ -154,7 +164,7 @@ function shouldFetchContext(
return Boolean(sessionId) && !draft && (messageCount > 0 || chatState !== 'idle')
}
export function ContextUsageIndicator({
export const ContextUsageIndicator = forwardRef<ContextUsageIndicatorHandle, Props>(function ContextUsageIndicator({
sessionId,
chatState,
messageCount,
@@ -163,7 +173,8 @@ export function ContextUsageIndicator({
draft = false,
compact = false,
refreshNonce = 0,
}: Props) {
hideTrigger = false,
}, handleRef) {
const t = useTranslation()
// `compact` also fires for the desktop composer, which narrows for the right
// panel rather than for touch, so the phone touch target keys off the
@@ -575,11 +586,19 @@ export function ContextUsageIndicator({
void refresh('manual')
}
useImperativeHandle(handleRef, () => ({
open: () => {
setDetailsOpen(true)
void refresh('manual')
},
}), [refresh])
return (
<div className="relative pointer-events-auto">
<button
ref={triggerRef}
type="button"
hidden={hideTrigger}
aria-label={ariaLabel}
aria-expanded={detailsOpen}
aria-haspopup="dialog"
@@ -649,4 +668,4 @@ export function ContextUsageIndicator({
)}
</div>
)
}
})
@@ -68,8 +68,15 @@ vi.mock('@tauri-apps/plugin-shell', () => ({
// Mock desktopRuntime.getServerBaseUrl
vi.mock('../../lib/desktopRuntime', () => ({
getServerBaseUrl: vi.fn(() => 'http://127.0.0.1:4321'),
isDesktopRuntime: () => false,
}))
const viewport = vi.hoisted(() => ({ mobile: false }))
vi.mock('../../hooks/useMobileViewport', () => ({ useMobileViewport: () => viewport.mobile }))
const turnDiff = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('../../api/sessions', () => ({ sessionsApi: { getTurnCheckpointDiff: turnDiff.get } }))
vi.mock('../../lib/systemFileOpen', () => ({
openLocalFileWithSystem: openSystemFileSpy,
resolveAbsoluteOpenPath: (path: string, workDir?: string) => (
@@ -718,3 +725,42 @@ describe('CurrentTurnChangeCard – collapse long file lists', () => {
expect(screen.getByText('chat.turnChangesShowMore')).toBeInTheDocument()
})
})
describe('CurrentTurnChangeCard on a phone', () => {
beforeEach(() => {
viewport.mobile = true
turnDiff.get.mockReset()
reviewOpenSpy.mockClear()
})
afterEach(() => {
viewport.mobile = false
cleanup()
})
it('opens the turn\'s change to a file in a full-height sheet, since there is no workspace beside the chat', async () => {
turnDiff.get.mockImplementation(async (_session: string, _turn: string, path: string) => ({
state: 'ok',
path,
diff: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-old line in ${path}\n+new line in ${path}\n`,
}))
renderExpandedCard(['src/todo.ts', 'test/todo.test.ts'])
fireEvent.click(screen.getByTitle('src/todo.ts'))
expect(reviewOpenSpy).not.toHaveBeenCalled()
expect(turnDiff.get).toHaveBeenCalledWith('s1', 'msg-1', 'src/todo.ts', 0, true)
expect(await screen.findByText(/new line in src\/todo\.ts/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'mobile.diff.next' }))
expect(await screen.findByText(/new line in test\/todo\.test\.ts/)).toBeInTheDocument()
})
it('says when a file has no line change to show', async () => {
turnDiff.get.mockResolvedValue({ state: 'ok', path: 'docs/spec.docx' })
renderExpandedCard(['docs/spec.docx'])
fireEvent.click(screen.getByTitle('docs/spec.docx'))
expect(await screen.findByText('mobile.diff.empty')).toBeInTheDocument()
})
})
@@ -17,6 +17,9 @@ import { useOpenTargetStore } from '../../stores/openTargetStore'
import { workspaceOpen } from '../../lib/workspace/openTarget'
import { isWorkspaceDocumentFile, isWorkspacePreviewableFile } from '../../lib/fileCapabilities'
import { openLocalFileWithSystem, reportOpenFailure } from '../../lib/systemFileOpen'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isDesktopRuntime } from '../../lib/desktopRuntime'
import { MobileTurnDiffSheet } from './MobileTurnDiffSheet'
type CurrentTurnChangeCardProps = {
sessionId: string
@@ -52,6 +55,10 @@ export function CurrentTurnChangeCard({
const filesId = useId()
const [openWith, setOpenWith] = useState<{ items: OpenWithItem[]; anchor: DOMRect; triggerEl: HTMLElement } | null>(null)
const [showAllFiles, setShowAllFiles] = useState(false)
// A phone has no workspace beside the chat to open a file in; the turn's
// change to it opens full height in a sheet instead.
const isMobile = useMobileViewport() && !isDesktopRuntime()
const [diffPath, setDiffPath] = useState<string | null>(null)
const files = useMemo<ChangedFileEntry[]>(
() => checkpoint.code.filesChanged
@@ -75,6 +82,10 @@ export function CurrentTurnChangeCard({
const hasUnverifiedChanges = restoreAvailable && unverifiedChangeSources.length > 0
const openChangedFile = useCallback((event: ReactMouseEvent<HTMLButtonElement>, fileEntry: ChangedFileEntry) => {
if (isMobile) {
setDiffPath(fileEntry.displayPath)
return
}
const renderItem = event.currentTarget.closest<HTMLElement>('[data-chat-render-item-key]')
const origin = {
sourceTurnKey: renderItem?.dataset.chatRenderItemKey ?? checkpoint.target.targetUserMessageId,
@@ -111,7 +122,7 @@ export function CurrentTurnChangeCard({
path: fileEntry.displayPath,
origin,
})
}, [checkpoint.target.targetUserMessageId, checkpoint.target.userMessageIndex, sessionId, files])
}, [checkpoint.target.targetUserMessageId, checkpoint.target.userMessageIndex, sessionId, files, isMobile])
const handleOpenWith = useCallback((event: ReactMouseEvent<HTMLButtonElement>, fileEntry: ChangedFileEntry) => {
event.stopPropagation()
@@ -237,7 +248,7 @@ export function CurrentTurnChangeCard({
const typeInfo = describeFileType(fileEntry.displayPath)
const workspacePreviewable = isWorkspacePreviewableFile(fileEntry.displayPath)
return (
<div key={fileEntry.apiPath} className="flex h-8 items-center gap-0.5 px-1.5">
<div key={fileEntry.apiPath} className={`flex ${isMobile ? 'h-11' : 'h-8'} items-center gap-0.5 px-1.5`}>
<button
type="button"
id={`turn-change-opener-${checkpoint.target.targetUserMessageId}-${encodeURIComponent(fileEntry.apiPath)}`}
@@ -305,6 +316,16 @@ export function CurrentTurnChangeCard({
)}
{openWith && <OpenWithMenu items={openWith.items} anchor={openWith.anchor} triggerEl={openWith.triggerEl} onClose={() => setOpenWith(null)} />}
{isMobile && checkpoint.target.targetUserMessageId ? (
<MobileTurnDiffSheet
sessionId={sessionId}
targetUserMessageId={checkpoint.target.targetUserMessageId}
userMessageIndex={checkpoint.target.userMessageIndex}
paths={files.map((entry) => entry.displayPath)}
openPath={diffPath}
onOpenPathChange={setDiffPath}
/>
) : null}
</section>
)
}
@@ -1564,6 +1564,12 @@ type MessageListProps = {
sessionId?: string | null
compact?: boolean
mobileLayout?: boolean
/**
* The host answers waiting requests from an approval bar in the composer's
* place (the phone session page). Permission, plan and question cards then
* shrink to a marker line here while they wait, so only one live copy exists.
*/
decisionsInComposer?: boolean
onOpenAgentRun?: (payload: OpenAgentRunPayload) => void
/**
* Lets a host that renders the list under a non-session id (an agent run's
@@ -2281,6 +2287,7 @@ export function MessageList({
sessionId,
compact = false,
mobileLayout = false,
decisionsInComposer = false,
onOpenAgentRun,
resolveAgentActivityTarget,
}: MessageListProps = {}) {
@@ -3703,6 +3710,7 @@ export function MessageList({
isLive={chatState !== 'idle' && index === renderItems.length - 1 && !hasTrailingStreamingItem}
disclosureKey={getRenderItemKey(item)}
revealToolUseId={navigationToolUseIdFor(item)}
activityPresentation={mobileLayout ? 'sheet' : 'inline'}
/>
) : item.kind === 'team_card' ? (
resolvedSessionId ? (() => {
@@ -3745,6 +3753,7 @@ export function MessageList({
isTurnOutputOwner={turnOutputOwnerIndexes.has(index)}
turnCompletion={turnCompletionByMessageId.get(item.message.id)}
supersededAskUserQuestionIds={supersededAskUserQuestionIds}
decisionsInComposer={decisionsInComposer}
navigationHighlighted={
item.message.type === 'tool_use' &&
navigationToolUseIdFor(item) === item.message.toolUseId
@@ -3996,6 +4005,7 @@ export const MessageBlock = memo(function MessageBlock({
isTurnOutputOwner,
turnCompletion,
supersededAskUserQuestionIds,
decisionsInComposer = false,
navigationHighlighted = false,
}: {
sessionId?: string | null
@@ -4015,6 +4025,7 @@ export const MessageBlock = memo(function MessageBlock({
isTurnOutputOwner?: boolean
turnCompletion?: TurnCompletion
supersededAskUserQuestionIds?: ReadonlySet<string>
decisionsInComposer?: boolean
/** A "locate in chat" jump landed on this tool call. */
navigationHighlighted?: boolean
}) {
@@ -4089,6 +4100,7 @@ export const MessageBlock = memo(function MessageBlock({
input={message.input}
result={toolResult?.content}
supersededByUserMessage={supersededAskUserQuestionIds?.has(message.toolUseId)}
markerWhenPending={decisionsInComposer}
/>
)
}
@@ -4133,6 +4145,7 @@ export const MessageBlock = memo(function MessageBlock({
input={message.input}
description={message.description}
displayName={message.displayName}
markerWhenPending={decisionsInComposer}
/>
)
case 'error': {
@@ -0,0 +1,182 @@
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../api/websocket', () => ({
wsManager: {
connect: vi.fn(),
disconnect: vi.fn(),
onConnectionState: vi.fn(() => () => {}),
onMessage: vi.fn(() => () => {}),
clearHandlers: vi.fn(),
send: vi.fn(),
},
}))
import { useChatStore, type PendingPermission } from '../../stores/chatStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { MobileApprovalDock } from './MobileApprovalDock'
import { PermissionDialog } from './PermissionDialog'
const SESSION = 'session-1'
const respondToPermission = vi.fn()
const bash: PendingPermission = {
requestId: 'perm-bash',
toolName: 'Bash',
toolUseId: 'toolu-bash',
input: { command: 'bun run build', description: 'Build the app' },
}
const edit: PendingPermission = {
requestId: 'perm-edit',
toolName: 'Edit',
toolUseId: 'toolu-edit',
input: { file_path: '/repo/src/LoginForm.tsx', old_string: 'a', new_string: 'b' },
}
const question: PendingPermission = {
requestId: 'perm-question',
toolName: 'AskUserQuestion',
toolUseId: 'toolu-question',
input: {
questions: [{
question: 'Which channel should the release go to?',
header: 'Channel',
options: [{ label: 'Stable' }, { label: 'Beta' }],
}],
},
}
const plan: PendingPermission = {
requestId: 'perm-plan',
toolName: 'ExitPlanMode',
toolUseId: 'toolu-plan',
input: { plan: '# Plan\n\n1. Split the dialog.', allowedPrompts: [] },
description: 'Exit plan mode?',
}
function seed(pending: PendingPermission[]) {
useChatStore.setState({
respondToPermission,
sessions: {
[SESSION]: {
messages: [],
chatState: 'permission_pending',
connectionState: 'connected',
pendingPermission: pending[0] ?? null,
pendingPermissions: Object.fromEntries(pending.map((request) => [request.requestId, request])),
},
},
} as never)
}
describe('MobileApprovalDock', () => {
beforeEach(() => {
respondToPermission.mockReset()
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState({ activeTabId: SESSION, tabs: [] })
})
it('answers a tool request in one tap from where the composer was', () => {
seed([bash])
render(<MobileApprovalDock sessionId={SESSION} />)
const dock = screen.getByTestId('mobile-approval-dock')
expect(dock).toHaveAttribute('data-kind', 'tool')
expect(dock).toHaveTextContent('bun run build')
fireEvent.click(within(dock).getByRole('button', { name: 'Allow' }))
expect(respondToPermission).toHaveBeenLastCalledWith(SESSION, 'perm-bash', true)
fireEvent.click(within(dock).getByRole('button', { name: 'Deny' }))
expect(respondToPermission).toHaveBeenLastCalledWith(SESSION, 'perm-bash', false)
fireEvent.click(within(dock).getByRole('button', { name: 'Allow for session' }))
expect(respondToPermission).toHaveBeenLastCalledWith(SESSION, 'perm-bash', true, { rule: 'always' })
})
it('denies with the reason the person types, which reaches the model', () => {
seed([bash])
render(<MobileApprovalDock sessionId={SESSION} />)
fireEvent.click(screen.getByRole('button', { name: 'Deny and tell Claude why' }))
const field = screen.getByLabelText('Reason for denying')
fireEvent.change(field, { target: { value: 'Run the tests first' } })
fireEvent.click(screen.getByRole('button', { name: 'Deny with this reason' }))
expect(respondToPermission).toHaveBeenCalledWith(SESSION, 'perm-bash', false, { denyMessage: 'Run the tests first' })
})
it('works through queued requests in arrival order and says how many wait', () => {
seed([bash, edit])
const { rerender } = render(<MobileApprovalDock sessionId={SESSION} />)
expect(screen.getByTestId('mobile-approval-dock')).toHaveTextContent('1 of 2')
expect(screen.getByTestId('mobile-approval-dock')).toHaveTextContent('bun run build')
act(() => seed([edit]))
rerender(<MobileApprovalDock sessionId={SESSION} />)
expect(screen.getByTestId('mobile-approval-dock')).toHaveTextContent('/repo/src/LoginForm.tsx')
expect(screen.getByTestId('mobile-approval-dock')).not.toHaveTextContent('of 2')
})
it('opens the full card in a sheet to read an edit before allowing it', () => {
seed([edit])
render(<MobileApprovalDock sessionId={SESSION} />)
fireEvent.click(screen.getByRole('button', { name: 'View details' }))
const sheet = screen.getByTestId('mobile-approval-sheet')
// The full card, with the same Allow the dock has.
expect(within(sheet).getByRole('button', { name: /^Allow: / })).toBeInTheDocument()
})
it('opens a question in a sheet instead of squeezing it into the bar', () => {
seed([question])
render(<MobileApprovalDock sessionId={SESSION} />)
const dock = screen.getByTestId('mobile-approval-dock')
expect(dock).toHaveAttribute('data-kind', 'question')
expect(dock).toHaveTextContent('Which channel should the release go to?')
expect(within(dock).queryByRole('button', { name: 'Allow' })).not.toBeInTheDocument()
fireEvent.click(within(dock).getByRole('button', { name: 'Answer' }))
expect(within(screen.getByTestId('mobile-approval-sheet')).getByText('Stable')).toBeInTheDocument()
})
it('sends a plan to a full-height sheet to review', () => {
seed([plan])
render(<MobileApprovalDock sessionId={SESSION} />)
expect(screen.getByTestId('mobile-approval-dock')).toHaveAttribute('data-kind', 'plan')
fireEvent.click(screen.getByRole('button', { name: 'Review the plan' }))
expect(within(screen.getByTestId('mobile-approval-sheet')).getByText('Split the dialog.')).toBeInTheDocument()
})
it('goes away once nothing is waiting', () => {
seed([])
render(<MobileApprovalDock sessionId={SESSION} />)
expect(screen.queryByTestId('mobile-approval-dock')).not.toBeInTheDocument()
})
})
describe('PermissionDialog marker mode', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState({ activeTabId: SESSION, tabs: [] })
})
it('leaves one marker line in the transcript while the dock holds the live card', () => {
seed([bash])
render(<PermissionDialog sessionId={SESSION} requestId="perm-bash" toolName="Bash" input={bash.input} markerWhenPending />)
expect(screen.getByTestId('pending-decision-marker')).toHaveTextContent('Answer below')
expect(screen.queryByRole('button', { name: /^Allow: / })).not.toBeInTheDocument()
})
it('goes back to the answered row once the request is resolved', () => {
seed([])
render(<PermissionDialog sessionId={SESSION} requestId="perm-bash" toolName="Bash" input={bash.input} markerWhenPending />)
expect(screen.queryByTestId('pending-decision-marker')).not.toBeInTheDocument()
expect(screen.getByText('Responded')).toBeInTheDocument()
})
})
@@ -0,0 +1,210 @@
import { useEffect, useId, useMemo, useState, type FormEvent } from 'react'
import { ArrowUp, ListChecks, MessageCircleQuestion, Shield } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Button } from '@/components/ui/Button'
import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet'
import { listPendingPermissions, useChatStore, type PendingPermission } from '../../stores/chatStore'
import { AskUserQuestion } from './AskUserQuestion'
import { PermissionDialog } from './PermissionDialog'
import { isExitPlanModeTool } from './PlanModePreview'
import { PERMISSION_TOOL_ICONS, extractToolDetails, getPermissionTitle } from './permissionPresentation'
type DecisionKind = 'tool' | 'plan' | 'question'
function kindOf(request: PendingPermission): DecisionKind {
if (request.toolName === 'AskUserQuestion') return 'question'
if (isExitPlanModeTool(request.toolName)) return 'plan'
return 'tool'
}
function firstQuestionText(input: unknown): string {
if (!input || typeof input !== 'object') return ''
const questions = (input as { questions?: unknown }).questions
if (!Array.isArray(questions)) return ''
const first = questions[0] as { question?: unknown } | undefined
return typeof first?.question === 'string' ? first.question : ''
}
/**
* The phone's answer to "something is waiting for you": while a request is
* open, this takes the composer's place at the bottom of the session, where
* the thumb and the eye already are, and the transcript above stays readable.
*
* A tool request is answered here in one tap. Typing a reason denies it with
* that reason, which is what a reply to a request means. A plan or a question
* needs room to read, so the bar opens the full card in a sheet. Requests are
* answered in the order they arrived; the count says how many are queued.
*/
export function MobileApprovalDock({ sessionId }: { sessionId: string }) {
const t = useTranslation()
const session = useChatStore((state) => state.sessions[sessionId])
const respondToPermission = useChatStore((state) => state.respondToPermission)
const pending = useMemo(() => listPendingPermissions(session), [session])
const current = pending[0]
const requestId = current?.requestId ?? null
const [detailOpen, setDetailOpen] = useState(false)
const [reasonOpen, setReasonOpen] = useState(false)
const [reason, setReason] = useState('')
const reasonId = useId()
// A new request starts closed and empty, whatever the last one was doing.
useEffect(() => {
setDetailOpen(false)
setReasonOpen(false)
setReason('')
}, [requestId])
if (!current) return null
const kind = kindOf(current)
const position = pending.length > 1 ? t('mobile.approval.position', { index: 1, total: pending.length }) : null
let Icon = PERMISSION_TOOL_ICONS[current.toolName] ?? Shield
let title: string
let detail = ''
if (kind === 'question') {
Icon = MessageCircleQuestion
title = t('mobile.approval.questionTitle')
detail = firstQuestionText(current.input)
} else if (kind === 'plan') {
Icon = ListChecks
title = t('permission.planReadyTitle')
detail = current.description ?? ''
} else {
title = getPermissionTitle(current.toolName, current.input, t, current.displayName)
detail = extractToolDetails(current.toolName, current.input, t).primary || current.description || current.toolName
}
const submitReason = (event: FormEvent) => {
event.preventDefault()
const message = reason.trim()
if (!message) return
respondToPermission(sessionId, current.requestId, false, { denyMessage: message })
}
return (
<div
data-testid="mobile-approval-dock"
data-kind={kind}
role="region"
aria-label={title}
className="flex flex-col gap-2.5 rounded-[var(--radius-xl)] border border-[var(--color-warning)] bg-[var(--color-warning-container)] p-3 shadow-[var(--shadow-raised)]"
>
<div className="flex min-w-0 items-start gap-2.5">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-surface-container-lowest)] text-[var(--color-on-warning-container)]">
<Icon size={16} strokeWidth={1.75} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="min-w-0 flex-1 truncate text-[14px] font-semibold leading-5 text-[var(--color-text-primary)]">{title}</h2>
{position ? (
<span className="shrink-0 text-[12px] tabular-nums text-[var(--color-on-warning-container)]">{position}</span>
) : null}
</div>
{detail ? (
<p
className={`mt-0.5 line-clamp-2 break-words text-[12px] leading-[1.5] text-[var(--color-text-secondary)] ${
kind === 'tool' ? 'font-mono' : ''
}`}
>
{detail}
</p>
) : null}
</div>
</div>
{kind === 'tool' ? (
<>
<div className="grid grid-cols-[1fr_1.4fr] gap-2">
<Button
variant="secondary"
size="lg"
block
onClick={() => respondToPermission(sessionId, current.requestId, false)}
>
{t('permission.deny')}
</Button>
<Button
variant="primary"
size="lg"
block
onClick={() => respondToPermission(sessionId, current.requestId, true)}
>
{t('permission.allow')}
</Button>
</div>
<div className="flex items-center justify-between gap-2">
<Button variant="ghost" size="md" onClick={() => setDetailOpen(true)}>
{t('mobile.approval.viewDetails')}
</Button>
<Button
variant="ghost"
size="md"
onClick={() => respondToPermission(sessionId, current.requestId, true, { rule: 'always' })}
>
{t('permission.allowForSession')}
</Button>
</div>
{reasonOpen ? (
<form onSubmit={submitReason} className="flex items-center gap-2">
<label htmlFor={reasonId} className="sr-only">{t('mobile.approval.reasonLabel')}</label>
<input
id={reasonId}
value={reason}
autoFocus
onChange={(event) => setReason(event.target.value)}
placeholder={t('mobile.approval.reasonPlaceholder')}
className="h-11 min-w-0 flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-[15px] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
/>
<Button
type="submit"
variant="accent"
size="lg"
shape="circle"
disabled={!reason.trim()}
aria-label={t('mobile.approval.denyWithReason')}
className="h-11 w-11 shrink-0"
icon={<ArrowUp size={18} strokeWidth={2} aria-hidden="true" />}
/>
</form>
) : (
<button
type="button"
onClick={() => setReasonOpen(true)}
className="min-h-9 text-center text-[12px] text-[var(--color-text-tertiary)] underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{t('mobile.approval.denyWithReasonHint')}
</button>
)}
</>
) : (
<Button variant="primary" size="lg" block onClick={() => setDetailOpen(true)}>
{kind === 'question' ? t('mobile.approval.answer') : t('mobile.approval.reviewPlan')}
</Button>
)}
<MobileBottomSheet
open={detailOpen}
onClose={() => setDetailOpen(false)}
title={title}
closeLabel={t('common.close')}
testId="mobile-approval-sheet"
tall
contentClassName="p-3"
>
{kind === 'question' ? (
<AskUserQuestion sessionId={sessionId} toolUseId={current.toolUseId ?? current.requestId} input={current.input} />
) : (
<PermissionDialog
sessionId={sessionId}
requestId={current.requestId}
toolName={current.toolName}
input={current.input}
description={current.description}
displayName={current.displayName}
/>
)}
</MobileBottomSheet>
</div>
)
}
@@ -0,0 +1,95 @@
import type { ReactNode } from 'react'
import { ChevronRight } from 'lucide-react'
import { useTranslation } from '@/i18n'
import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet'
import type { NewComposerMention } from '@/lib/composerMentions'
import { ComposerCapabilityMenu } from './ComposerCapabilityMenu'
import type { CapabilityAction, CapabilityMenuSection } from './capabilityMenuModel'
export type MobileComposerSetting = {
key: string
icon: ReactNode
label: string
/** The current choice, shown on the right of the row. */
value?: ReactNode
disabled?: boolean
onSelect: () => void
}
type Props = {
open: boolean
onClose: () => void
menuId: string
settings: MobileComposerSetting[]
sections: CapabilityMenuSection[]
cwd?: string
referencesLoading?: boolean
referencesError?: string | boolean | null
onSelectFile?: (mention: NewComposerMention) => void
onAction: (action: CapabilityAction) => void
}
/**
* What the phone composer's + opens: the session's settings that left the
* toolbar (permission mode, context usage, model) on top, then the same
* capability menu the desktop + shows — attachments, files, skills, commands —
* laid out for a finger. A setting row closes this sheet and opens that
* control's own sheet, so two sheets never stack.
*/
export function MobileComposerSheet({
open,
onClose,
menuId,
settings,
sections,
cwd,
referencesLoading,
referencesError,
onSelectFile,
onAction,
}: Props) {
const t = useTranslation()
return (
<MobileBottomSheet
open={open}
onClose={onClose}
title={t('chat.mobileSheet.title')}
closeLabel={t('common.close')}
testId="mobile-composer-sheet"
>
{settings.length > 0 ? (
<div role="group" aria-label={t('chat.mobileSheet.sessionSettings')} className="border-b border-[var(--color-border)] p-2">
{settings.map((setting) => (
<button
key={setting.key}
type="button"
disabled={setting.disabled}
onClick={setting.onSelect}
className="flex min-h-12 w-full items-center gap-3 rounded-[var(--radius-md)] px-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] active:bg-[var(--color-surface-hover)] disabled:opacity-50"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]">
{setting.icon}
</span>
<span className="min-w-0 flex-1 truncate text-[14px] font-medium text-[var(--color-text-primary)]">{setting.label}</span>
{setting.value ? (
<span className="max-w-[50%] truncate text-[13px] text-[var(--color-text-tertiary)]">{setting.value}</span>
) : null}
<ChevronRight size={16} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
))}
</div>
) : null}
<ComposerCapabilityMenu
id={menuId}
presentation="sheet"
sections={sections}
cwd={cwd}
referencesLoading={referencesLoading}
referencesError={referencesError}
onSelectFile={onSelectFile}
onAction={onAction}
onClose={onClose}
/>
</MobileBottomSheet>
)
}
@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { IconButton } from '@/components/ui/IconButton'
import { LoadingState } from '@/components/ui/LoadingState'
import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet'
import { sessionsApi } from '../../api/sessions'
import { WorkspaceDiffSurface } from '../workspace/WorkspaceDiffSurface'
type Props = {
sessionId: string
targetUserMessageId: string
userMessageIndex?: number
/** Workspace-relative paths of the files this turn changed, in card order. */
paths: string[]
/** The file to show; null closes the sheet. */
openPath: string | null
onOpenPathChange: (path: string | null) => void
}
type DiffState =
| { status: 'loading' }
| { status: 'ready'; diff: string }
| { status: 'empty' }
| { status: 'error'; message: string | null }
/**
* A turn's change to one file, full height on the phone. The desktop opens
* this in the workspace's review tab beside the chat; a phone has no room
* beside anything, so the same recorded change opens here instead — unified,
* lines wrapped, with the turn's other files a tap away.
*/
export function MobileTurnDiffSheet({
sessionId,
targetUserMessageId,
userMessageIndex,
paths,
openPath,
onOpenPathChange,
}: Props) {
const t = useTranslation()
const [state, setState] = useState<DiffState>({ status: 'loading' })
const index = openPath ? paths.indexOf(openPath) : -1
useEffect(() => {
if (!openPath) return
let cancelled = false
setState({ status: 'loading' })
void sessionsApi.getTurnCheckpointDiff(sessionId, targetUserMessageId, openPath, userMessageIndex, true)
.then((result) => {
if (cancelled) return
if (result.state === 'ok' && result.diff) setState({ status: 'ready', diff: result.diff })
else if (result.state === 'ok' || result.state === 'missing') setState({ status: 'empty' })
else setState({ status: 'error', message: result.error ?? null })
})
.catch((error: unknown) => {
if (!cancelled) setState({ status: 'error', message: error instanceof Error ? error.message : null })
})
return () => {
cancelled = true
}
}, [openPath, sessionId, targetUserMessageId, userMessageIndex])
return (
<MobileBottomSheet
open={openPath !== null}
onClose={() => onOpenPathChange(null)}
title={<span className="block truncate font-mono text-[13px]">{openPath}</span>}
ariaLabel={openPath ?? t('mobile.diff.title')}
closeLabel={t('common.close')}
testId="mobile-turn-diff-sheet"
tall
headerExtra={paths.length > 1 ? (
<div className="flex items-center justify-between gap-2">
<IconButton
size="2xl"
tone="secondary"
icon={<ChevronLeft size={18} strokeWidth={1.75} aria-hidden="true" />}
label={t('mobile.diff.previous')}
disabled={index <= 0}
onClick={() => onOpenPathChange(paths[index - 1] ?? null)}
/>
<span className="text-[12px] tabular-nums text-[var(--color-text-tertiary)]">
{t('mobile.approval.position', { index: index + 1, total: paths.length })}
</span>
<IconButton
size="2xl"
tone="secondary"
icon={<ChevronRight size={18} strokeWidth={1.75} aria-hidden="true" />}
label={t('mobile.diff.next')}
disabled={index < 0 || index >= paths.length - 1}
onClick={() => onOpenPathChange(paths[index + 1] ?? null)}
/>
</div>
) : undefined}
>
{state.status === 'loading' ? (
<LoadingState label={t('common.loading')} variant="inline" size="md" className="py-10" />
) : state.status === 'ready' && openPath ? (
<WorkspaceDiffSurface
value={state.diff}
path={openPath}
mode="unified"
wrapLines
hideSingleFileHeader
className="bg-[var(--color-surface)]"
/>
) : (
<p role={state.status === 'error' ? 'alert' : undefined} className="px-4 py-8 text-center text-[13px] text-[var(--color-text-tertiary)]">
{state.status === 'error' ? (state.message ?? t('mobile.diff.failed')) : t('mobile.diff.empty')}
</p>
)}
</MobileBottomSheet>
)
}
@@ -0,0 +1,24 @@
import { CircleAlert } from 'lucide-react'
import { useTranslation } from '../../i18n'
/**
* Where a waiting request sits in the transcript on a phone. The card itself
* is answered from the approval bar that takes the composer's place, so here
* it shrinks to one line that says what is waiting and where to answer it —
* two live copies of one card would each hold half of an answer.
*/
export function PendingDecisionMarker({ title }: { title: string }) {
const t = useTranslation()
return (
<div
role="group"
aria-label={title}
data-testid="pending-decision-marker"
className="mb-4 flex min-h-10 min-w-0 items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--color-warning)] bg-[var(--color-warning-container)] px-3 py-2 text-[13px] text-[var(--color-on-warning-container)]"
>
<CircleAlert aria-hidden="true" size={16} strokeWidth={1.75} className="shrink-0" />
<span className="min-w-0 flex-1 truncate font-medium">{title}</span>
<span className="shrink-0 text-[12px]">{t('mobile.approval.answerBelow')}</span>
</div>
)
}
@@ -1,23 +1,12 @@
import { useState } from 'react'
import {
Bot,
Box,
Check,
ChevronDown,
ChevronUp,
Download,
FilePen,
FilePlus,
FileSearch,
FileText,
Folder,
Globe,
ListChecks,
NotebookPen,
Search,
Shield,
SquareTerminal,
type LucideIcon,
} from 'lucide-react'
import { getPendingPermission, useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
@@ -25,7 +14,6 @@ import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useProviderStore } from '../../stores/providerStore'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n'
import { Badge, StatusDot } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { ModelSelector } from '../controls/ModelSelector'
@@ -33,6 +21,8 @@ import { resolveDefaultRuntimeSelection } from '../../lib/runtimeSelection'
import type { RuntimeSelection } from '../../types/runtime'
import type { PermissionUpdate } from '../../types/chat'
import { DiffViewer } from './DiffViewer'
import { PERMISSION_TOOL_ICONS, extractToolDetails, getPermissionTitle } from './permissionPresentation'
import { PendingDecisionMarker } from './PendingDecisionMarker'
import {
PlanPreviewCard,
buildPlanApprovalPermissionUpdates,
@@ -49,27 +39,11 @@ type Props = {
input: unknown
description?: string
displayName?: string
}
/**
* The glyph in the card's icon block, per tool. The block itself is always the
* warning pair (`--color-warning-container` / `--color-on-warning-container`):
* the card means "waiting for you", whatever the tool. It used to tint the
* block by appending an alpha suffix to a `var(...)` string (`${color}18`),
* which is not a color at all and rendered no background.
*/
const TOOL_ICONS: Record<string, LucideIcon> = {
Bash: SquareTerminal,
Edit: FilePen,
Write: FilePlus,
Read: FileText,
Glob: Search,
Grep: FileSearch,
Agent: Bot,
WebSearch: Globe,
WebFetch: Download,
NotebookEdit: NotebookPen,
Skill: Box,
/**
* While the request waits, draw one marker line instead of the card. The
* phone answers it from the approval bar in the composer's place.
*/
markerWhenPending?: boolean
}
/** Card shell shared by the permission and plan-approval cards (「素」). */
@@ -84,69 +58,6 @@ const RESOLVED_ROW =
const INSET_BLOCK =
'rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2.5 font-mono text-xs leading-[1.6] text-[var(--color-text-primary)]'
/**
* Extract human-readable detail lines from tool input.
*/
function extractToolDetails(toolName: string, input: unknown, t: (key: TranslationKey, params?: Record<string, string | number>) => string): { primary: string; secondary?: string } {
const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
switch (toolName) {
case 'Bash': {
const cmd = typeof obj.command === 'string' ? obj.command : ''
const desc = typeof obj.description === 'string' ? obj.description : undefined
return { primary: cmd, secondary: desc }
}
case 'Edit': {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
return { primary: filePath, secondary: obj.old_string ? t('permission.replacingContent') : undefined }
}
case 'Write': {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
return { primary: filePath }
}
case 'Read': {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
return { primary: filePath }
}
case 'Glob':
return { primary: typeof obj.pattern === 'string' ? obj.pattern : '' }
case 'Grep':
return { primary: typeof obj.pattern === 'string' ? obj.pattern : '' }
case 'Agent':
return { primary: typeof obj.description === 'string' ? obj.description : '' }
case 'WebSearch':
return { primary: typeof obj.query === 'string' ? obj.query : '' }
case 'WebFetch':
return { primary: typeof obj.url === 'string' ? obj.url : '' }
default:
return { primary: typeof input === 'string' ? input : JSON.stringify(input, null, 2) }
}
}
function getPermissionTitle(
toolName: string,
input: unknown,
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
displayName?: string,
) {
const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
const fileName = filePath ? filePath.split('/').pop() || filePath : ''
const actor = displayName || 'Claude'
switch (toolName) {
case 'Edit':
case 'Write':
return fileName
? t('permission.allowEditFile', { actor, toolName, fileName })
: t('permission.allowEditFileGeneric', { actor, toolName: toolName.toLowerCase() })
case 'Bash':
return t('permission.allowBash', { actor })
default:
return t('permission.allowTool', { actor, toolName })
}
}
function renderPermissionPreview(toolName: string, input: unknown) {
const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
const filePath = typeof obj.file_path === 'string' ? obj.file_path : 'file'
@@ -172,7 +83,7 @@ function renderPermissionPreview(toolName: string, input: unknown) {
return null
}
export function PermissionDialog({ sessionId, requestId, toolName, input, description, displayName }: Props) {
export function PermissionDialog({ sessionId, requestId, toolName, input, description, displayName, markerWhenPending = false }: Props) {
const { respondToPermission } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const targetSessionId = sessionId ?? activeTabId
@@ -191,11 +102,12 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
input={input}
description={description}
isPending={isPending}
markerWhenPending={markerWhenPending}
/>
)
}
const ToolIcon = TOOL_ICONS[toolName] ?? Shield
const ToolIcon = PERMISSION_TOOL_ICONS[toolName] ?? Shield
const details = extractToolDetails(toolName, input, t)
const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
const preview = renderPermissionPreview(toolName, input)
@@ -203,6 +115,10 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
const allowRawToggle = !preview
const permissionContext = (details.primary || description || toolName).slice(0, 160)
if (isPending && markerWhenPending) {
return <PendingDecisionMarker title={title} />
}
if (!isPending) {
return (
<div role="group" aria-label={`${title}: ${permissionContext}`} className={RESOLVED_ROW}>
@@ -333,12 +249,14 @@ function ExitPlanModePermissionDialog({
input,
description,
isPending,
markerWhenPending,
}: {
sessionId?: string | null
requestId: string
input: unknown
description?: string
isPending: boolean
markerWhenPending: boolean
}) {
const { respondToPermission } = useChatStore()
const t = useTranslation()
@@ -406,6 +324,10 @@ function ExitPlanModePermissionDialog({
approve({ permissionUpdates: buildPlanApprovalPermissionUpdates(mode, preview.allowedPrompts) })
}
if (isPending && markerWhenPending) {
return <PendingDecisionMarker title={t('permission.planReadyTitle')} />
}
if (!isPending) {
return (
<div className={RESOLVED_ROW}>
@@ -122,6 +122,8 @@ type Props = {
disclosureKey?: string
/** A "locate in chat" jump targets this call: open its run and mark its row. */
revealToolUseId?: string
/** See ActivityGroup: the phone folds each run into a card. */
activityPresentation?: 'inline' | 'sheet'
}
export type OpenAgentRunPayload = {
@@ -146,6 +148,7 @@ export const ToolCallGroup = memo(function ToolCallGroup({
isLive = false,
disclosureKey,
revealToolUseId,
activityPresentation = 'inline',
}: Props) {
const resolvedSteps = useMemo(() => steps ?? toActivitySteps(toolCalls), [steps, toolCalls])
const memoryActivity = getMemoryToolActivity(toolCalls, resultMap)
@@ -180,6 +183,7 @@ export const ToolCallGroup = memo(function ToolCallGroup({
isStreaming={isStreaming}
disclosureKey={disclosureKey}
revealToolUseId={revealToolUseId}
activityPresentation={activityPresentation}
/>
) : null}
</div>
@@ -202,6 +206,7 @@ export const ToolCallGroup = memo(function ToolCallGroup({
isLive={isLive}
disclosureKey={disclosureKey}
revealToolUseId={revealToolUseId}
activityPresentation={activityPresentation}
/>
)
})
@@ -223,6 +228,7 @@ function ToolCallGroupContent({
isLive = false,
disclosureKey,
revealToolUseId,
activityPresentation = 'inline',
}: ContentProps) {
const awaitingToolUseIds = useAwaitingToolUseIds(sessionId)
const toolCalls = activityStepToolCalls(steps)
@@ -282,6 +288,7 @@ function ToolCallGroupContent({
showOpenRun={showOpenRun}
isStreaming={isStreaming}
revealToolUseId={revealToolUseId}
activityPresentation={activityPresentation}
/>
))}
</div>
@@ -338,6 +345,7 @@ function ToolCallGroupContent({
disclosureKey={disclosureKey}
revealToolUseId={revealToolUseId}
awaitingToolUseIds={awaitingToolUseIds}
presentation={activityPresentation}
/>
)
}
+13 -4
View File
@@ -10,6 +10,7 @@ import { splitTextByUrls } from '../../lib/urlBoundary'
import { AttachmentGallery } from './AttachmentGallery'
import { MessageActionBar, type MessageBranchAction, type MessageEditAction } from './MessageActionBar'
import { UserMessageEditor } from './UserMessageEditor'
import { useMessageActionMenu } from './useMessageActionMenu'
import type { UserMessageEditDraft } from './userMessageEdit'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
@@ -75,6 +76,10 @@ export const UserMessage = memo(function UserMessage({
: undefined,
[editAction],
)
// A teammate's message offers copy only; the user's own adds branch and edit.
const actionMenu = useMessageActionMenu(teammateFrom
? { copyText: hasText ? content : undefined, timestamp }
: { copyText: hasText ? content : undefined, branchAction, editAction: actionBarEditAction, timestamp })
// The operator's prompt is literal text, NOT markdown — `**`, `#` and file
// paths have to stay exactly as typed. Teammate traffic is rendered separately
@@ -118,7 +123,8 @@ export const UserMessage = memo(function UserMessage({
<div
data-message-shell="teammate"
data-teammate-from={teammateFrom}
className="group relative flex min-w-0 max-w-[82%] flex-col items-start lg:max-w-[680px]"
{...actionMenu.pressProps}
className={`group relative flex min-w-0 max-w-[82%] flex-col items-start lg:max-w-[680px] ${actionMenu.pressClassName}`}
>
<div className="mb-1.5 flex min-w-0 items-center gap-2 px-0.5 text-xs text-[var(--color-text-tertiary)]">
{teammateAvatarSrc ? (
@@ -167,7 +173,7 @@ export const UserMessage = memo(function UserMessage({
)}
</div>
{hasText && (
{hasText && !actionMenu.enabled && (
<MessageActionBar
copyText={content}
copyLabel={t('chat.copyPrompt')}
@@ -176,6 +182,7 @@ export const UserMessage = memo(function UserMessage({
timestamp={timestamp}
/>
)}
{actionMenu.sheet}
</div>
</div>
)
@@ -206,7 +213,8 @@ export const UserMessage = memo(function UserMessage({
<div className="flex justify-end">
<div
data-message-shell="user"
className="group relative flex min-w-0 max-w-[82%] flex-col items-end lg:max-w-[640px]"
{...actionMenu.pressProps}
className={`group relative flex min-w-0 max-w-[82%] flex-col items-end lg:max-w-[640px] ${actionMenu.pressClassName}`}
>
<div className="flex max-w-full flex-col items-end gap-2">
{collaboration ? <div className="px-0.5 text-[11px] text-[var(--color-text-tertiary)]">
@@ -233,7 +241,7 @@ export const UserMessage = memo(function UserMessage({
)}
</div>
{(hasText || actionBarEditAction) && (
{(hasText || actionBarEditAction) && !actionMenu.enabled && (
<MessageActionBar
copyText={content}
copyLabel={t('chat.copyPrompt')}
@@ -244,6 +252,7 @@ export const UserMessage = memo(function UserMessage({
timestamp={timestamp}
/>
)}
{actionMenu.sheet}
</div>
</div>
)
@@ -0,0 +1,99 @@
import {
Bot,
Box,
Download,
FilePen,
FilePlus,
FileSearch,
FileText,
Globe,
NotebookPen,
Search,
SquareTerminal,
type LucideIcon,
} from 'lucide-react'
import type { TranslationKey } from '../../i18n'
/**
* The glyph in the card's icon block, per tool. The block itself is always the
* warning pair (`--color-warning-container` / `--color-on-warning-container`):
* the card means "waiting for you", whatever the tool. It used to tint the
* block by appending an alpha suffix to a `var(...)` string (`${color}18`),
* which is not a color at all and rendered no background.
*/
export const PERMISSION_TOOL_ICONS: Record<string, LucideIcon> = {
Bash: SquareTerminal,
Edit: FilePen,
Write: FilePlus,
Read: FileText,
Glob: Search,
Grep: FileSearch,
Agent: Bot,
WebSearch: Globe,
WebFetch: Download,
NotebookEdit: NotebookPen,
Skill: Box,
}
/**
* Extract human-readable detail lines from tool input.
*/
export function extractToolDetails(toolName: string, input: unknown, t: (key: TranslationKey, params?: Record<string, string | number>) => string): { primary: string; secondary?: string } {
const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
switch (toolName) {
case 'Bash': {
const cmd = typeof obj.command === 'string' ? obj.command : ''
const desc = typeof obj.description === 'string' ? obj.description : undefined
return { primary: cmd, secondary: desc }
}
case 'Edit': {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
return { primary: filePath, secondary: obj.old_string ? t('permission.replacingContent') : undefined }
}
case 'Write': {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
return { primary: filePath }
}
case 'Read': {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
return { primary: filePath }
}
case 'Glob':
return { primary: typeof obj.pattern === 'string' ? obj.pattern : '' }
case 'Grep':
return { primary: typeof obj.pattern === 'string' ? obj.pattern : '' }
case 'Agent':
return { primary: typeof obj.description === 'string' ? obj.description : '' }
case 'WebSearch':
return { primary: typeof obj.query === 'string' ? obj.query : '' }
case 'WebFetch':
return { primary: typeof obj.url === 'string' ? obj.url : '' }
default:
return { primary: typeof input === 'string' ? input : JSON.stringify(input, null, 2) }
}
}
export function getPermissionTitle(
toolName: string,
input: unknown,
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
displayName?: string,
) {
const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
const fileName = filePath ? filePath.split('/').pop() || filePath : ''
const actor = displayName || 'Claude'
switch (toolName) {
case 'Edit':
case 'Write':
return fileName
? t('permission.allowEditFile', { actor, toolName, fileName })
: t('permission.allowEditFileGeneric', { actor, toolName: toolName.toLowerCase() })
case 'Bash':
return t('permission.allowBash', { actor })
default:
return t('permission.allowTool', { actor, toolName })
}
}
@@ -0,0 +1,150 @@
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const viewport = vi.hoisted(() => ({ mobile: true }))
const clipboard = vi.hoisted(() => ({ copy: vi.fn(async () => true) }))
vi.mock('../../hooks/useMobileViewport', () => ({ useMobileViewport: () => viewport.mobile }))
vi.mock('../../lib/clipboard', () => ({ copyTextToClipboard: clipboard.copy }))
import { useSettingsStore } from '../../stores/settingsStore'
import { useUIStore } from '../../stores/uiStore'
import { LONG_PRESS_DELAY_MS } from '../../hooks/useLongPress'
import { UserMessage } from './UserMessage'
class TestPointerEvent extends MouseEvent {
pointerType: string
isPrimary: boolean
constructor(type: string, init: MouseEventInit & { pointerType?: string; isPrimary?: boolean } = {}) {
super(type, init)
this.pointerType = init.pointerType ?? 'touch'
this.isPrimary = init.isPrimary ?? true
}
}
const TIMESTAMP = new Date('2026-10-06T14:35:00').getTime()
function hold(element: HTMLElement) {
vi.useFakeTimers()
fireEvent.pointerDown(element, { pointerType: 'touch', isPrimary: true, clientX: 4, clientY: 4 })
act(() => { vi.advanceTimersByTime(LONG_PRESS_DELAY_MS) })
fireEvent.pointerUp(element)
vi.useRealTimers()
}
function renderPrompt(overrides: Partial<Parameters<typeof UserMessage>[0]> = {}) {
const onBranch = vi.fn()
const onStart = vi.fn()
render(
<UserMessage
content="把登录页错误提示改成 i18n"
timestamp={TIMESTAMP}
branchAction={{ label: 'Branch from here', onBranch }}
editAction={{
label: 'Edit and resend',
editing: false,
submitting: false,
disabled: false,
getDraft: () => ({ text: '', attachments: [] }) as never,
onStart,
onCancel: vi.fn(),
onDraftChange: vi.fn(),
onSubmit: vi.fn(),
}}
{...overrides}
/>,
)
const shell = document.querySelector<HTMLElement>('[data-message-shell="user"]')!
return { shell, onBranch, onStart }
}
describe('message actions on a phone', () => {
beforeAll(() => {
if (!('PointerEvent' in window)) {
Object.defineProperty(window, 'PointerEvent', { configurable: true, value: TestPointerEvent })
}
})
beforeEach(() => {
viewport.mobile = true
clipboard.copy.mockClear()
useSettingsStore.setState({ locale: 'en' })
useUIStore.setState({ toasts: [] })
})
it('has no row of icons under the message; holding it opens the actions with the exact time', () => {
const { shell } = renderPrompt()
expect(shell.querySelector('[data-message-actions]')).toBeNull()
expect(shell).toHaveClass('select-none')
hold(shell)
const sheet = screen.getByTestId('message-action-sheet')
expect(sheet).toHaveTextContent('02:35')
expect(within(sheet).getAllByRole('menuitem').map((item) => item.textContent)).toEqual([
'Copy', 'Select text', 'Edit and resend', 'Branch from here',
])
})
it('copies the prompt and says so', async () => {
const { shell } = renderPrompt()
hold(shell)
await act(async () => {
fireEvent.click(within(screen.getByTestId('message-action-sheet')).getByRole('menuitem', { name: 'Copy' }))
})
expect(clipboard.copy).toHaveBeenCalledWith('把登录页错误提示改成 i18n')
expect(useUIStore.getState().toasts.at(-1)?.type).toBe('success')
expect(screen.queryByTestId('message-action-sheet')).not.toBeInTheDocument()
})
it('opens the text where the browser can select it, since holding no longer does', () => {
const { shell } = renderPrompt()
hold(shell)
fireEvent.click(screen.getByRole('menuitem', { name: 'Select text' }))
const text = screen.getByTestId('message-select-text')
expect(text).toHaveTextContent('把登录页错误提示改成 i18n')
expect(text).toHaveClass('select-text')
})
it('says why an edit cannot be made instead of hiding it', () => {
const { shell, onStart } = renderPrompt({
editAction: {
label: 'Edit and resend',
editing: false,
submitting: false,
disabled: true,
disabledReason: 'Stop the current turn first',
getDraft: () => ({ text: '', attachments: [] }) as never,
onStart: vi.fn(),
onCancel: vi.fn(),
onDraftChange: vi.fn(),
onSubmit: vi.fn(),
},
})
hold(shell)
const edit = screen.getByRole('menuitem', { name: /Edit and resend/ })
expect(edit).toBeDisabled()
expect(edit).toHaveTextContent('Stop the current turn first')
expect(onStart).not.toHaveBeenCalled()
})
it('branches from the message', () => {
const { shell, onBranch } = renderPrompt()
hold(shell)
fireEvent.click(screen.getByRole('menuitem', { name: 'Branch from here' }))
expect(onBranch).toHaveBeenCalledTimes(1)
})
it('keeps the hover bar on the desktop and does nothing on hold', () => {
viewport.mobile = false
const { shell } = renderPrompt()
expect(shell.querySelector('[data-message-actions]')).not.toBeNull()
hold(shell)
expect(screen.queryByTestId('message-action-sheet')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,166 @@
import { useState, type ReactNode } from 'react'
import { Copy, GitFork, Pencil, TextCursorInput } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet'
import { useLongPress } from '../../hooks/useLongPress'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { copyTextToClipboard } from '../../lib/clipboard'
import { isDesktopRuntime } from '../../lib/desktopRuntime'
import { formatExactMessageTimestamp } from '../../lib/formatMessageTimestamp'
import { useSettingsStore } from '../../stores/settingsStore'
import { useUIStore } from '../../stores/uiStore'
import type { MessageBranchAction, MessageEditAction } from './MessageActionBar'
type Input = {
copyText?: string
branchAction?: MessageBranchAction
editAction?: MessageEditAction
timestamp?: number
}
type MessageActionMenu = {
/** True on a touch layout: the hover bar is replaced by press-and-hold. */
enabled: boolean
/** Spread on the message shell. Inert unless `enabled`. */
pressProps: ReturnType<typeof useLongPress>
/** Add to the shell's classes: stops the browser's own long-press callout. */
pressClassName: string
/** The sheets; render it once next to the message. */
sheet: ReactNode
}
/**
* Press and hold a message on a phone for its actions: copy, select text,
* edit and resend, branch from here. On the desktop these are a hover bar
* under the message; a phone has no hover, and a bar kept always visible put
* a row of icons under every message. The sheet's header carries the exact
* time, and an edit that cannot be made says why instead of disappearing.
*
* Holding a message no longer starts the browser's own text selection, so
* "Select text" opens the message as plain text where selection works.
*/
export function useMessageActionMenu({ copyText, branchAction, editAction, timestamp }: Input): MessageActionMenu {
const t = useTranslation()
const locale = useSettingsStore((state) => state.locale)
const enabled = useMobileViewport() && !isDesktopRuntime()
const [mode, setMode] = useState<'closed' | 'actions' | 'select'>('closed')
const text = copyText?.trim() ? copyText : ''
const hasActions = Boolean(text || branchAction || editAction)
const longPress = useLongPress({ onLongPress: () => setMode('actions'), disabled: !enabled || !hasActions })
const close = () => setMode('closed')
const copy = async () => {
close()
const ok = await copyTextToClipboard(text)
useUIStore.getState().addToast({
type: ok ? 'success' : 'error',
message: ok ? t('common.copied') : t('common.copyFailed'),
})
}
const exactTime = typeof timestamp === 'number' ? formatExactMessageTimestamp(timestamp, locale) : ''
const sheet = enabled ? (
<>
<MobileBottomSheet
open={mode === 'actions'}
onClose={close}
title={exactTime || t('chat.messageActions.title')}
ariaLabel={t('chat.messageActions.title')}
closeLabel={t('common.close')}
testId="message-action-sheet"
>
<div role="menu" aria-label={t('chat.messageActions.title')} className="flex flex-col p-2">
{text ? (
<ActionRow icon={<Copy size={18} strokeWidth={1.75} aria-hidden="true" />} onClick={() => void copy()}>
{t('common.copy')}
</ActionRow>
) : null}
{text ? (
<ActionRow icon={<TextCursorInput size={18} strokeWidth={1.75} aria-hidden="true" />} onClick={() => setMode('select')}>
{t('chat.messageActions.selectText')}
</ActionRow>
) : null}
{editAction ? (
<ActionRow
icon={<Pencil size={18} strokeWidth={1.75} aria-hidden="true" />}
disabled={editAction.disabled}
description={editAction.disabled ? editAction.disabledReason : undefined}
onClick={() => {
close()
editAction.onEdit()
}}
>
{editAction.label}
</ActionRow>
) : null}
{branchAction ? (
<ActionRow
icon={<GitFork size={18} strokeWidth={1.75} aria-hidden="true" />}
disabled={branchAction.loading}
onClick={() => {
close()
branchAction.onBranch()
}}
>
{branchAction.label}
</ActionRow>
) : null}
</div>
</MobileBottomSheet>
<MobileBottomSheet
open={mode === 'select'}
onClose={close}
title={t('chat.messageActions.selectText')}
closeLabel={t('common.close')}
testId="message-select-sheet"
tall
contentClassName="p-4"
>
<div
data-testid="message-select-text"
className="select-text whitespace-pre-wrap break-words text-[15px] leading-[1.7] text-[var(--color-text-primary)] [-webkit-touch-callout:default]"
>
{text}
</div>
</MobileBottomSheet>
</>
) : null
return {
enabled,
pressProps: longPress,
pressClassName: enabled && hasActions ? 'select-none [-webkit-touch-callout:none]' : '',
sheet,
}
}
function ActionRow({
icon,
disabled = false,
description,
onClick,
children,
}: {
icon: ReactNode
disabled?: boolean
description?: string
onClick: () => void
children: ReactNode
}) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={onClick}
className="flex min-h-12 items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left text-[var(--color-text-primary)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] active:bg-[var(--color-surface-hover)] disabled:text-[var(--color-text-tertiary)]"
>
<span className="shrink-0 text-[var(--color-text-secondary)]">{icon}</span>
<span className="flex min-w-0 flex-col">
<span className="text-[15px]">{children}</span>
{description ? <span className="text-[12px] text-[var(--color-text-tertiary)]">{description}</span> : null}
</span>
</button>
)
}
@@ -51,7 +51,8 @@ vi.mock('../../i18n', () => ({
}[key] ?? key),
}))
import { PermissionModeSelector } from './PermissionModeSelector'
import { createRef } from 'react'
import { PermissionModeSelector, type PermissionModeSelectorHandle } from './PermissionModeSelector'
import { useChatStore, type PerSessionState } from '../../stores/chatStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useSessionStore } from '../../stores/sessionStore'
@@ -178,6 +179,34 @@ describe('PermissionModeSelector', () => {
expect(screen.getByRole('menuitem', { name: /Auto accept edits/ })).toBeInTheDocument()
})
it('keeps the phone toolbar free of the trigger until some writes run unattended', () => {
viewportMocks.isMobile = true
useTabStore.setState({ activeTabId: 'tab', tabs: [] })
useChatStore.setState({ sessions: { tab: { ...makeChatSession('idle'), permissionMode: 'default' } } })
render(<PermissionModeSelector compact trigger="elevatedOnly" />)
expect(screen.queryByRole('button', { name: 'Ask permissions' })).not.toBeInTheDocument()
act(() => useChatStore.setState({ sessions: { tab: { ...makeChatSession('idle'), permissionMode: 'bypassPermissions' } } }))
expect(screen.getByRole('button', { name: 'Bypass permissions' })).toHaveClass('h-11', 'w-11')
})
it('opens from its handle with no trigger on screen, and not mid-turn', () => {
viewportMocks.isMobile = true
useTabStore.setState({ activeTabId: 'tab', tabs: [] })
useChatStore.setState({ sessions: { tab: makeChatSession('idle') } })
const handle = createRef<PermissionModeSelectorHandle>()
render(<PermissionModeSelector ref={handle} compact trigger="elevatedOnly" />)
act(() => handle.current?.open())
expect(screen.getByRole('dialog', { name: 'Execution Permissions' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
act(() => useChatStore.setState({ sessions: { tab: makeChatSession('streaming') } }))
act(() => handle.current?.open())
expect(screen.queryByRole('dialog', { name: 'Execution Permissions' })).not.toBeInTheDocument()
})
it('uses the active tab workspace when showing the bypass confirmation path', () => {
useSessionStore.setState({
activeSessionId: 'previous-session',
@@ -1,5 +1,5 @@
import { useSideChatStore } from '@/stores/sideChatStore'
import { useState, useRef, useEffect, useCallback, useId } from 'react'
import { forwardRef, useState, useRef, useEffect, useCallback, useId, useImperativeHandle } from 'react'
import DOMPurify from 'dompurify'
import { Check, ChevronDown, CirclePlay, DraftingCompass, Folder, Gavel, ShieldCheck, Zap, type LucideIcon } from 'lucide-react'
import { useDismissable } from '@/hooks/useDismissable'
@@ -16,6 +16,7 @@ import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet'
import { ActionDialog } from '@/components/ui/ActionDialog'
import { AutoModeOptInDialog } from './AutoModeOptInDialog'
import { useResolvedPermissionMode } from './permissionModeState'
import {
COMPOSER_MENU_ITEM,
COMPOSER_MENU_ITEM_ACTIVE,
@@ -55,10 +56,22 @@ function ItemIcon({ mode }: { mode: PermissionMode }) {
return <Icon aria-hidden="true" size={16} strokeWidth={1.75} />
}
export type PermissionModeSelectorHandle = {
/** Opens the mode menu, as a tap on the trigger would. Ignored mid-turn. */
open: () => void
}
type Props = {
sessionId?: string
workDir?: string
compact?: boolean
/**
* `chip` always shows the trigger. `elevatedOnly` hides it while every write
* still asks first: the phone composer keeps the mode in its + sheet, and
* only puts the trigger on the toolbar once some writes run unattended — the
* one state worth a glance.
*/
trigger?: 'chip' | 'elevatedOnly'
menuPlacement?: 'top' | 'bottom'
/** Controlled mode: override current value */
value?: PermissionMode
@@ -66,18 +79,19 @@ type Props = {
onChange?: (mode: PermissionMode) => void
}
export function PermissionModeSelector({ sessionId, workDir: workDirProp, compact = false, menuPlacement = 'top', value, onChange }: Props = {}) {
export const PermissionModeSelector = forwardRef<PermissionModeSelectorHandle, Props>(function PermissionModeSelector(
{ sessionId, workDir: workDirProp, compact = false, trigger = 'chip', menuPlacement = 'top', value, onChange }: Props = {},
handleRef,
) {
const t = useTranslation()
const isMobile = useMobileViewport() && !isDesktopRuntime()
const {
permissionMode: storeMode,
autoModeOptInAccepted,
acceptAutoModeOptIn,
} = useSettingsStore()
const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode)
const selectedTabId = useTabStore((s) => s.activeTabId)
const activeTabId = sessionId ?? selectedTabId
const livePermissionMode = useChatStore(s => activeTabId ? s.sessions[activeTabId]?.permissionMode : undefined)
const sideChat = useSideChatStore(s => activeTabId ? s.entries[activeTabId] : undefined)
const sessions = useSessionStore((s) => s.sessions)
const chatState = useChatStore((s) =>
@@ -147,9 +161,7 @@ export function PermissionModeSelector({ sessionId, workDir: workDirProp, compac
const activeSession = activeTabId
? sessions.find((s) => s.id === activeTabId)
: null
const currentMode = isControlled
? value
: livePermissionMode || (activeSession?.permissionMode as PermissionMode | undefined) || sideChat?.permissionMode || storeMode
const currentMode = useResolvedPermissionMode(sessionId, value)
const workDir = workDirProp || activeSession?.workDir || sideChat?.workDir || '~'
// A quiet 28px chip on the composer row (risk dot + label + chevron); the
// compact desktop form keeps only the mode glyph, and the phone form grows
@@ -191,6 +203,15 @@ export function PermissionModeSelector({ sessionId, workDir: workDirProp, compac
const closeMenu = useCallback(() => setOpen(false), [])
const openMenu = useCallback(() => {
const actionTabId = sessionId ?? useTabStore.getState().activeTabId
if ((useChatStore.getState().sessions[actionTabId ?? '']?.chatState ?? 'idle') !== 'idle') return
interactionTabIdRef.current = actionTabId
setOpen(true)
}, [sessionId])
useImperativeHandle(handleRef, () => ({ open: openMenu }), [openMenu])
// `ref` wraps the trigger and the desktop popup; `menuRef` covers the sheet,
// which portals out of it. `stopEscapePropagation` keeps one Escape from
// closing both this menu and a dialog it was opened inside.
@@ -268,8 +289,11 @@ export function PermissionModeSelector({ sessionId, workDir: workDirProp, compac
</>
)
const showTrigger = trigger === 'chip' || MODE_DOT_TONE[currentMode] !== 'success'
return (
<div ref={ref} className="relative">
{showTrigger ? (
<button
onClick={() => {
const actionTabId = sessionId ?? useTabStore.getState().activeTabId
@@ -309,6 +333,7 @@ export function PermissionModeSelector({ sessionId, workDir: workDirProp, compac
</>
)}
</button>
) : null}
{open && (
isMobile ? (
@@ -455,4 +480,4 @@ export function PermissionModeSelector({ sessionId, workDir: workDirProp, compac
/>
</div>
)
}
})
@@ -0,0 +1,34 @@
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useSideChatStore } from '../../stores/sideChatStore'
import { useTabStore } from '../../stores/tabStore'
import type { PermissionMode } from '../../types/settings'
/**
* The mode a composer is running under: the explicit value when controlled,
* else the live session's, then the saved session's, the side chat's, and the
* global default. The selector and the phone's composer sheet both read it
* here so they cannot disagree.
*/
export function useResolvedPermissionMode(sessionId?: string, value?: PermissionMode): PermissionMode {
const storeMode = useSettingsStore((s) => s.permissionMode)
const selectedTabId = useTabStore((s) => s.activeTabId)
const activeTabId = sessionId ?? selectedTabId
const livePermissionMode = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.permissionMode : undefined)
const sideChatMode = useSideChatStore((s) => activeTabId ? s.entries[activeTabId]?.permissionMode : undefined)
const savedMode = useSessionStore((s) => (
activeTabId ? s.sessions.find((session) => session.id === activeTabId)?.permissionMode : undefined
)) as PermissionMode | undefined
if (value !== undefined) return value
return livePermissionMode || savedMode || sideChatMode || storeMode
}
export const PERMISSION_MODE_LABEL_KEYS = {
default: 'permMode.label.default',
acceptEdits: 'permMode.label.acceptEdits',
auto: 'permMode.label.auto',
plan: 'permMode.label.plan',
bypassPermissions: 'permMode.label.bypassPermissions',
dontAsk: 'permMode.label.dontAsk',
} as const satisfies Record<PermissionMode, string>
+32 -124
View File
@@ -12,7 +12,7 @@ import {
const mocks = vi.hoisted(() => ({
initializeDesktopServerUrl: vi.fn(),
isTauriRuntime: false,
isMobile: false,
layout: 'desktop' as 'desktop' | 'phone' | 'tablet',
fetchAll: vi.fn(),
restoreTabs: vi.fn(),
connectToSession: vi.fn(),
@@ -46,8 +46,17 @@ vi.mock('../../stores/settingsStore', () => ({
selector({ fetchAll: mocks.fetchAll }),
}))
vi.mock('../../hooks/useMobileViewport', () => ({
useMobileViewport: () => mocks.isMobile,
vi.mock('../mobile/mobileShellLayout', () => ({
useMobileShellLayout: () => mocks.layout,
}))
// The phone and tablet frame has its own suite against real stores; here only
// the hand-off is checked: which layout it is given and whether the desktop UI
// preferences request reaches it, since no Sidebar mounts to consume it.
vi.mock('../mobile/MobileShell', () => ({
MobileShell: ({ layout, preferencesRequest }: { layout: string; preferencesRequest: Promise<unknown> | null }) => (
<div data-testid="mobile-shell-stub" data-layout={layout} data-has-preferences={preferencesRequest ? 'yes' : 'no'} />
),
}))
vi.mock('../../stores/tabStore', () => {
@@ -120,15 +129,6 @@ vi.mock('./ContentRouter', () => ({
ContentRouter: () => <section>content loaded</section>,
}))
// The real one subscribes to the chat store, which this file replaces with a
// `{ getState }` stub. Its behaviour has its own test; here only the wiring —
// which session it is told is on screen, and where it sits — is checked.
vi.mock('./MobileAttentionDot', () => ({
MobileAttentionDot: ({ activeSessionId }: { activeSessionId: string | null }) => (
<span data-testid="mobile-attention-dot-stub" data-active-session={activeSessionId ?? ''} />
),
}))
vi.mock('./TabBar', () => ({
TabBar: () => <nav>tabs loaded</nav>,
}))
@@ -160,7 +160,7 @@ describe('AppShell boot flow', () => {
hydrateProjectDisplayNames({}, Number.MAX_SAFE_INTEGER)
})
mocks.isTauriRuntime = false
mocks.isMobile = false
mocks.layout = 'desktop'
mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456')
mocks.fetchAll.mockResolvedValue(undefined)
mocks.restoreTabs.mockResolvedValue(undefined)
@@ -526,8 +526,8 @@ describe('AppShell boot flow', () => {
expect(screen.queryByText('h5 connection view')).not.toBeInTheDocument()
})
it('hydrates project display names before the closed H5 drawer mounts Sidebar', async () => {
mocks.isMobile = true
it('hydrates project display names on a phone, where no sidebar mounts to do it', async () => {
mocks.layout = 'phone'
mocks.getDesktopUiPreferences.mockResolvedValueOnce({
exists: true,
preferences: {
@@ -548,129 +548,37 @@ describe('AppShell boot flow', () => {
render(<AppShell />)
await screen.findByText('content loaded')
const shell = await screen.findByTestId('mobile-shell-stub')
expect(shell).toHaveAttribute('data-layout', 'phone')
// The phone list reads hidden projects from this request.
expect(shell).toHaveAttribute('data-has-preferences', 'yes')
expect(screen.queryByText('sidebar loaded')).not.toBeInTheDocument()
expect(screen.queryByText('tabs loaded')).not.toBeInTheDocument()
await waitFor(() => {
expect(resolveProjectDisplayName('/workspace/project')).toBe('Mobile alias')
})
expect(mocks.getDesktopUiPreferences).toHaveBeenCalledTimes(1)
await act(async () => {
fireEvent.click(screen.getByTestId('mobile-sidebar-toggle'))
await Promise.resolve()
})
expect(screen.getByText('sidebar loaded')).toBeInTheDocument()
expect(mocks.getDesktopUiPreferences).toHaveBeenCalledTimes(1)
})
it('renders a mobile drawer toggle and backdrop in browser H5 mode', async () => {
mocks.isMobile = true
it('hands a touch tablet to the mobile shell instead of the desktop chrome', async () => {
mocks.layout = 'tablet'
render(<AppShell />)
await screen.findByText('content loaded')
await waitFor(() => {
expect(useUIStore.getState().sidebarOpen).toBe(false)
})
expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('data-state', 'closed')
expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('inert')
expect(screen.queryByText('sidebar loaded')).not.toBeInTheDocument()
expect(screen.getByTestId('mobile-sidebar-toggle')).toBeInTheDocument()
expect(screen.queryByTestId('sidebar-backdrop')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('mobile-sidebar-toggle'))
expect(useUIStore.getState().sidebarOpen).toBe(true)
expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('data-state', 'open')
expect(screen.getByText('sidebar loaded')).toBeInTheDocument()
expect(screen.getByTestId('sidebar-backdrop')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('sidebar-backdrop'))
expect(useUIStore.getState().sidebarOpen).toBe(false)
expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('data-state', 'closed')
})
it('shares the mobile drawer row with the active session title', async () => {
mocks.isMobile = true
mocks.tabState.activeTabId = 'session-mobile'
mocks.tabState.tabs = [
{ sessionId: 'session-mobile', title: 'Fallback tab title', type: 'session', status: 'running' },
]
useSessionStore.setState({
sessions: [{
id: 'session-mobile',
title: 'Analyze recent commits',
createdAt: '2026-05-10T00:00:00.000Z',
modifiedAt: new Date().toISOString(),
messageCount: 7,
projectPath: '/tmp/project',
workDir: '/tmp/project',
workDirExists: true,
}],
activeSessionId: 'session-mobile',
isLoading: false,
error: null,
})
render(<AppShell />)
await screen.findByText('content loaded')
const header = screen.getByTestId('mobile-session-header')
expect(header).toHaveTextContent('Analyze recent commits')
expect(header).toHaveTextContent('session.active')
expect(header).toHaveTextContent('session.messages')
// 44px — the hamburger is the primary mobile navigation target, and the
// platform minimum for primary touch targets is 44, not the 40 it shipped
// at (IconButton's own size doc had the two tiers reversed).
expect(screen.getByTestId('mobile-sidebar-toggle')).toHaveClass('h-11', 'w-11')
// The hamburger's corner is where the drawer's waiting marks get announced
// from, and the dot is told which session is on screen so it can leave that
// one out. It shares a wrapper with the button so it can sit on its corner.
const dot = screen.getByTestId('mobile-attention-dot-stub')
expect(dot).toHaveAttribute('data-active-session', 'session-mobile')
expect(screen.getByTestId('mobile-sidebar-toggle').parentElement).toContainElement(dot)
})
it('does not put the waiting dot on a desktop window, which has the tab strip instead', async () => {
mocks.isMobile = false
render(<AppShell />)
await screen.findByText('content loaded')
expect(screen.queryByTestId('mobile-attention-dot-stub')).not.toBeInTheDocument()
})
it('keeps browser H5 settings active alongside existing chat tabs', async () => {
mocks.isMobile = true
mocks.tabState.activeTabId = '__settings__'
mocks.tabState.tabs = [
{ sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' },
{ sessionId: 'session-1', title: 'Existing session', type: 'session', status: 'idle' },
]
render(<AppShell />)
await screen.findByText('content loaded')
expect(await screen.findByTestId('mobile-shell-stub')).toHaveAttribute('data-layout', 'tablet')
expect(screen.queryByTestId('sidebar-shell')).not.toBeInTheDocument()
expect(screen.queryByText('tabs loaded')).not.toBeInTheDocument()
expect(mocks.setActiveTab).not.toHaveBeenCalled()
expect(mocks.tabState.activeTabId).toBe('__settings__')
expect(screen.getByTestId('mobile-session-header')).toHaveTextContent('sidebar.settings')
})
it('keeps mobile settings active when no chat session exists', async () => {
mocks.isMobile = true
mocks.tabState.activeTabId = '__settings__'
mocks.tabState.tabs = [{ sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' }]
it('keeps the sidebar, tab strip and resize handle on a desktop window', async () => {
mocks.layout = 'desktop'
render(<AppShell />)
await screen.findByText('content loaded')
expect(mocks.tabState.activeTabId).toBe('__settings__')
expect(mocks.setActiveTab).not.toHaveBeenCalled()
expect(screen.getByText('sidebar loaded')).toBeInTheDocument()
expect(screen.getByText('tabs loaded')).toBeInTheDocument()
expect(screen.getByTestId('sidebar-resize-handle')).toBeInTheDocument()
expect(screen.queryByTestId('mobile-shell-stub')).not.toBeInTheDocument()
})
})
+30 -155
View File
@@ -1,11 +1,8 @@
import { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react'
import { Menu, X } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { Sidebar } from './Sidebar'
import { ContentRouter } from './ContentRouter'
import { ToastContainer } from '@/components/layout/Toast'
import { UpdateChecker } from '@/components/layout/UpdateChecker'
import { StatusDot } from '@/components/ui/Badge'
import { IconButton } from '@/components/ui/IconButton'
import { useSettingsStore } from '../../stores/settingsStore'
import { useUIStore, type SettingsTab } from '../../stores/uiStore'
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
@@ -28,7 +25,6 @@ import {
} from '../../stores/projectDisplayNameStore'
import { openDesktopNotificationTarget } from '../../lib/desktopNotificationNavigation'
import { TabBar } from './TabBar'
import { MobileAttentionDot } from './MobileAttentionDot'
import { WorkspaceHeaderProvider } from './WorkspaceHeaderContext'
import { StartupErrorView } from './StartupErrorView'
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
@@ -36,7 +32,8 @@ import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTranslation } from '../../i18n'
import { H5ConnectionView } from './H5ConnectionView'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { MobileShell } from '../mobile/MobileShell'
import { useMobileShellLayout } from '../mobile/mobileShellLayout'
import type { Tab } from '../../stores/tabStore'
function isChatTab(tab: Tab | undefined) {
@@ -46,13 +43,10 @@ function isChatTab(tab: Tab | undefined) {
export function AppShell() {
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const toggleSidebar = useUIStore((s) => s.toggleSidebar)
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen)
const [ready, setReady] = useState(false)
const [startupError, setStartupError] = useState<string | null>(null)
const [h5StartupError, setH5StartupError] = useState<H5ConnectionRequiredError | null>(null)
const [bootstrapNonce, setBootstrapNonce] = useState(0)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
const [desktopUiPreferencesRequest, setDesktopUiPreferencesRequest] = useState<
Promise<DesktopUiPreferencesResponse> | null
>(null)
@@ -61,33 +55,17 @@ export function AppShell() {
}, [])
const t = useTranslation()
const desktopRuntime = isDesktopRuntime()
const isMobileShell = useMobileViewport() && !desktopRuntime
const shellLayout = useMobileShellLayout(desktopRuntime)
const tabs = useTabStore((s) => s.tabs)
const activeTabId = useTabStore((s) => s.activeTabId)
const setActiveTab = useTabStore((s) => s.setActiveTab)
const sessions = useSessionStore((s) => s.sessions)
const activeSession = activeTabId
? sessions.find((session) => session.id === activeTabId) ?? null
: null
const wasMobileShellRef = useRef(false)
const sidebarWidth = useUIStore((s) => s.sidebarWidth)
const effectiveSidebarOpen = isMobileShell ? mobileSidebarOpen : sidebarOpen
const sidebarResize = useSidebarResize(!isMobileShell)
const sidebarResize = useSidebarResize(shellLayout === 'desktop')
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId)
const isActiveChatTab = isChatTab(activeTab)
const mobileSessionTitle = activeSession?.title || activeTab?.title || t('session.untitled')
const mobileSessionUpdated = (() => {
if (!activeSession?.modifiedAt) return ''
const diff = Date.now() - new Date(activeSession.modifiedAt).getTime()
if (diff < 60000) return t('session.timeJustNow')
if (diff < 3600000) return t('session.timeMinutes', { n: Math.floor(diff / 60000) })
if (diff < 86400000) return t('session.timeHours', { n: Math.floor(diff / 3600000) })
return t('session.timeDays', { n: Math.floor(diff / 86400000) })
})()
const sidebarHiddenProps: HTMLAttributes<HTMLDivElement> & { inert?: '' } =
isMobileShell && !effectiveSidebarOpen
? { 'aria-hidden': true, inert: '' }
: {}
useEffect(() => {
const sessionStore = useSessionStore.getState()
@@ -219,45 +197,6 @@ export function AppShell() {
useKeyboardShortcuts()
useElectronWindowDragRegions()
useEffect(() => {
if (isMobileShell && !wasMobileShellRef.current) {
setMobileSidebarOpen(false)
setSidebarOpen(false)
}
if (!isMobileShell && wasMobileShellRef.current) {
setMobileSidebarOpen(false)
}
wasMobileShellRef.current = isMobileShell
}, [isMobileShell, setSidebarOpen])
useEffect(() => {
if (!ready || !isMobileShell) return
if (isChatTab(activeTab) || activeTab?.type === 'settings' || (!activeTab && !activeTabId)) return
const nextChatTab = tabs.find(isChatTab)
if (nextChatTab) {
setActiveTab(nextChatTab.sessionId)
return
}
useTabStore.setState({ activeTabId: null })
}, [activeTab, activeTabId, isMobileShell, ready, setActiveTab, tabs])
const setEffectiveSidebarOpen = (open: boolean) => {
if (isMobileShell) {
setMobileSidebarOpen(open)
setSidebarOpen(open)
return
}
setSidebarOpen(open)
}
const toggleEffectiveSidebar = () => {
if (isMobileShell) {
setEffectiveSidebarOpen(!mobileSidebarOpen)
return
}
toggleSidebar()
}
if (!desktopRuntime && h5StartupError) {
return (
<H5ConnectionView
@@ -280,106 +219,42 @@ export function AppShell() {
)
}
if (shellLayout !== 'desktop') {
return <MobileShell layout={shellLayout} preferencesRequest={desktopUiPreferencesRequest} />
}
return (
<div className={`app-shell app-shell-viewport flex overflow-hidden bg-[var(--color-surface)]${isMobileShell ? ' app-shell--mobile' : ''}`}>
{isMobileShell && effectiveSidebarOpen ? (
<button
type="button"
data-testid="sidebar-backdrop"
className="app-shell-backdrop fixed inset-0 z-[var(--z-scrim)] border-0 p-0"
aria-label={t('sidebar.collapse')}
onClick={() => setEffectiveSidebarOpen(false)}
/>
) : null}
<div className="app-shell app-shell-viewport flex overflow-hidden bg-[var(--color-surface)]">
<div
id="sidebar-shell"
ref={sidebarResize.shellRef}
data-testid="sidebar-shell"
data-state={effectiveSidebarOpen ? 'open' : 'closed'}
data-mobile={isMobileShell ? 'true' : 'false'}
className={`sidebar-shell${isMobileShell ? ' sidebar-shell--mobile' : ''}`}
{...sidebarHiddenProps}
data-state={sidebarOpen ? 'open' : 'closed'}
data-mobile="false"
className="sidebar-shell"
>
{!isMobileShell || effectiveSidebarOpen ? (
<Sidebar
isMobile={isMobileShell}
onRequestClose={() => setEffectiveSidebarOpen(false)}
desktopUiPreferencesRequest={desktopUiPreferencesRequest}
onDesktopUiPreferencesConsumed={consumeDesktopUiPreferencesRequest}
/>
) : null}
{!isMobileShell ? (
<div
data-testid="sidebar-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label={t('sidebar.resize')}
aria-valuenow={effectiveSidebarOpen ? sidebarWidth : 0}
tabIndex={0}
className="sidebar-resize-handle"
{...sidebarResize.handleProps}
/>
) : null}
<Sidebar
desktopUiPreferencesRequest={desktopUiPreferencesRequest}
onDesktopUiPreferencesConsumed={consumeDesktopUiPreferencesRequest}
/>
<div
data-testid="sidebar-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label={t('sidebar.resize')}
aria-valuenow={sidebarOpen ? sidebarWidth : 0}
tabIndex={0}
className="sidebar-resize-handle"
{...sidebarResize.handleProps}
/>
</div>
<main
id="content-area"
data-sidebar-state={effectiveSidebarOpen ? 'open' : 'closed'}
className={`min-w-0 flex-1 flex flex-col overflow-hidden${isMobileShell ? ' app-shell-main--mobile' : ''}`}
data-sidebar-state={sidebarOpen ? 'open' : 'closed'}
className="min-w-0 flex-1 flex flex-col overflow-hidden"
>
{isMobileShell ? (
<div
data-testid="mobile-session-header"
className="flex shrink-0 items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2"
>
<span className="relative inline-flex shrink-0">
<IconButton
data-testid="mobile-sidebar-toggle"
icon={effectiveSidebarOpen
? <X size={20} strokeWidth={1.75} aria-hidden="true" />
: <Menu size={20} strokeWidth={1.75} aria-hidden="true" />}
label={effectiveSidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
onClick={toggleEffectiveSidebar}
size="2xl"
aria-controls="sidebar-shell"
aria-expanded={effectiveSidebarOpen}
/>
{/* 手机没有 tab 栏,抽屉是切换会话的唯一入口,也是等待标志唯一能被
找到的地方:别的会话在等人时,在汉堡按钮上提一下。 */}
<MobileAttentionDot activeSessionId={activeTabId} />
</span>
{activeTab?.type === 'settings' ? (
<h1 className="min-w-0 flex-1 truncate text-[15px] font-semibold leading-tight text-[var(--color-text-primary)]">{t('sidebar.settings')}</h1>
) : isActiveChatTab ? (
<div className="min-w-0 flex-1">
<h1 className="truncate text-[15px] font-semibold leading-tight text-[var(--color-text-primary)]">
{mobileSessionTitle}
</h1>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap text-[11px] text-[var(--color-text-tertiary)]">
{activeTab?.status === 'running' ? (
<span className="flex shrink-0 items-center gap-1 text-[var(--color-text-secondary)]">
<StatusDot tone="info" pulse />
{t('session.active')}
</span>
) : null}
{activeSession?.messageCount !== undefined && activeSession.messageCount > 0 ? (
<>
{activeTab?.status === 'running' ? <span aria-hidden="true">·</span> : null}
<span>{t('session.messages', { count: activeSession.messageCount })}</span>
</>
) : null}
{mobileSessionUpdated ? (
<>
{(activeTab?.status === 'running') || ((activeSession?.messageCount ?? 0) > 0) ? <span aria-hidden="true">·</span> : null}
<span className="truncate">{t('session.lastUpdated', { time: mobileSessionUpdated })}</span>
</>
) : null}
</div>
</div>
) : null}
</div>
) : null}
<WorkspaceHeaderProvider>
{!isMobileShell ? <TabBar /> : null}
<TabBar />
<ContentRouter />
</WorkspaceHeaderProvider>
</main>
@@ -10,7 +10,15 @@ import { SubagentRunPage, TeamMemberRunPage } from '../../pages/SubagentRunPage'
import { AgentTeamsWorkbenchTab } from '../agentTeams/AgentTeamsWorkbenchTab'
import { WorkspaceBrowserGuestLayer } from '../workbench/WorkspaceBrowserGuestLayer'
export function ContentRouter() {
type ContentRouterProps = {
/**
* What shows when no tab is active. The desktop shows the new-session page;
* the phone shows its session list with the new-task composer under it.
*/
homePage?: ReactNode
}
export function ContentRouter({ homePage }: ContentRouterProps = {}) {
const activeTabId = useTabStore((s) => s.activeTabId)
const tabs = useTabStore((s) => s.tabs)
const activeTabType = tabs.find((t) => t.sessionId === activeTabId)?.type
@@ -41,7 +49,7 @@ export function ContentRouter() {
let page: ReactNode = null
if (!activeTabId || !activeTabType) {
page = <EmptySession />
page = homePage ?? <EmptySession />
} else if (activeTabType === 'settings') {
page = <Settings />
} else if (activeTabType === 'scheduled') {
+13 -105
View File
@@ -354,7 +354,7 @@ function makeDesktopUiPreferencesResponse({
}
}
function SidebarDrawerHarness({ request }: { request: Promise<DesktopUiPreferencesResponse> }) {
function SidebarRemountHarness({ request }: { request: Promise<DesktopUiPreferencesResponse> }) {
const [open, setOpen] = useState(true)
const [preferencesRequest, setPreferencesRequest] = useState<
Promise<DesktopUiPreferencesResponse> | null
@@ -363,11 +363,10 @@ function SidebarDrawerHarness({ request }: { request: Promise<DesktopUiPreferenc
return (
<>
<button type="button" onClick={() => setOpen((current) => !current)}>
{open ? 'Close drawer harness' : 'Open drawer harness'}
{open ? 'Unmount sidebar harness' : 'Mount sidebar harness'}
</button>
{open && (
<Sidebar
isMobile
desktopUiPreferencesRequest={preferencesRequest}
onDesktopUiPreferencesConsumed={(consumedRequest) => {
setPreferencesRequest((current) => current === consumedRequest ? null : current)
@@ -1739,13 +1738,13 @@ describe('Sidebar', () => {
expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual(['/workspace/alpha'])
})
it('invalidates stale bootstrap preferences before a mobile drawer remount', async () => {
it('invalidates stale bootstrap preferences before a sidebar remount', async () => {
const preferencesResponse = createDeferred<DesktopUiPreferencesResponse>()
useSessionStore.setState({
sessions: [makeSession('alpha-1', 'Alpha Session', '/workspace/alpha', new Date().toISOString())],
})
render(<SidebarDrawerHarness request={preferencesResponse.promise} />)
render(<SidebarRemountHarness request={preferencesResponse.promise} />)
fireEvent.click(screen.getByRole('button', { name: 'Project actions for alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Hide from Sidebar' }))
@@ -1760,8 +1759,8 @@ describe('Sidebar', () => {
})
})
fireEvent.click(screen.getByRole('button', { name: 'Close drawer harness' }))
fireEvent.click(screen.getByRole('button', { name: 'Open drawer harness' }))
fireEvent.click(screen.getByRole('button', { name: 'Unmount sidebar harness' }))
fireEvent.click(screen.getByRole('button', { name: 'Mount sidebar harness' }))
expect(screen.queryByTestId('sidebar-project-group-workspace-alpha')).not.toBeInTheDocument()
await act(async () => {
@@ -2309,47 +2308,6 @@ describe('Sidebar', () => {
expect(within(screen.getByRole('button', { name: /Worktree run/ })).getByText('worktree')).toHaveClass('sr-only')
})
it('keeps mobile navigation focused on chat sessions', async () => {
const onRequestClose = vi.fn()
createSession.mockResolvedValue('session-mobile-new')
useSessionStore.setState({
sessions: [
{
id: 'session-1',
title: 'Open Session',
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
},
],
})
render(<Sidebar isMobile onRequestClose={onRequestClose} />)
expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Extension Market' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(useTabStore.getState().activeTabId).toBe('__settings__')
expect(onRequestClose).toHaveBeenCalledTimes(1)
onRequestClose.mockClear()
fireEvent.click(screen.getByRole('button', { name: /Open Session/ }))
expect(onRequestClose).toHaveBeenCalledTimes(1)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'New Session' }))
})
await waitFor(() => {
expect(createSession).toHaveBeenCalled()
})
expect(onRequestClose).toHaveBeenCalledTimes(2)
})
it('keeps one unified extension market entry in desktop navigation', () => {
render(<Sidebar />)
@@ -2761,65 +2719,15 @@ describe('Sidebar', () => {
})
})
// The whole drawer is touch-only: it has no hover, and nothing can be focused
// through `pointer-events: none`. Every control gated on `group-hover` was
// therefore either dead or an invisible tap target, and the 53 tests above
// never saw it because they all render the desktop sidebar.
describe('touch drawer controls', () => {
const renderWithProject = (isMobile: boolean) => {
useSessionStore.setState({
sessions: [makeSession('alpha-1', 'Alpha newest', '/workspace/alpha', new Date('2026-05-15T10:00:00.000Z').toISOString())],
})
return render(<Sidebar isMobile={isMobile} />)
}
it('keeps the project row actions hover-gated on desktop', () => {
renderWithProject(false)
const actions = screen.getByRole('button', { name: 'Project actions for alpha' })
expect(actions).toHaveClass('h-7', 'w-7')
expect(actions.parentElement).toHaveClass('pointer-events-none', 'opacity-0')
it('keeps the project row actions hover-gated on desktop', () => {
useSessionStore.setState({
sessions: [makeSession('alpha-1', 'Alpha newest', '/workspace/alpha', new Date('2026-05-15T10:00:00.000Z').toISOString())],
})
render(<Sidebar />)
it('leaves the project row actions tappable at 44px in the drawer', () => {
renderWithProject(true)
const actions = screen.getByRole('button', { name: 'Project actions for alpha' })
const create = screen.getByRole('button', { name: 'New session in alpha' })
expect(actions).toHaveClass('h-11', 'w-11')
expect(create).toHaveClass('h-11', 'w-11')
// Both live in one row, so they need a gap wide enough not to catch a
// thumb aimed at the other.
expect(actions.parentElement).toHaveClass('opacity-100', 'gap-1.5')
expect(actions.parentElement).not.toHaveClass('pointer-events-none')
})
it('stops rendering the projects header actions as an invisible tap target', () => {
renderWithProject(true)
// These kept `pointer-events` while sitting at `opacity: 0` — visually
// absent on a phone, yet still firing on tap.
const menu = screen.getByRole('button', { name: 'Project menu' })
expect(menu).toHaveClass('h-11', 'w-11')
expect(menu.parentElement).toHaveClass('opacity-100')
expect(menu.parentElement).not.toHaveClass('opacity-0')
})
it('raises the search row and overflow toggle to the touch minimum', () => {
renderWithProject(true)
expect(screen.getByRole('button', { name: 'Refresh sessions' })).toHaveClass('h-11', 'w-11')
expect(screen.getByRole('button', { name: 'Batch manage' })).toHaveClass('h-11', 'w-11')
// Same flex row as the two above; at h-9 it left the row ragged.
expect(screen.getAllByRole('button', { name: 'Search chats' })[0]).toHaveClass('h-11')
})
it('raises the task view bell to the touch minimum as well', () => {
renderWithProject(true)
// 铃铛跟旁边的折叠按钮同处标题行;停在 32px 会是这行里唯一打不中的目标。
expect(screen.getByRole('button', { name: 'Task view' })).toHaveClass('h-11', 'w-11')
})
const actions = screen.getByRole('button', { name: 'Project actions for alpha' })
expect(actions).toHaveClass('h-7', 'w-7')
expect(actions.parentElement).toHaveClass('pointer-events-none', 'opacity-0')
})
describe('task view', () => {
+75 -363
View File
@@ -47,6 +47,22 @@ import { collectAttentionIds } from '../../lib/sessionAttention'
import { getSessionWorkspaceState, getSessionSeedWorkDir } from '../../lib/sessionWorkspace'
import { detectPlatform } from '../../lib/workspace/shortcuts'
import { SessionAttentionMark } from './SessionAttentionMark'
import {
buildSidebarProjectPreferences,
hasSidebarProjectPreferences,
normalizeSidebarProjectPreferences,
readCachedSidebarProjectPreferences,
readStoredProjectHidden,
readStoredProjectOrder,
readStoredProjectOrganization,
readStoredProjectPins,
readStoredProjectSortBy,
writeCachedSidebarProjectPreferences,
type SidebarProjectOrganization,
type SidebarProjectSortBy,
} from './sidebarProjectPreferenceStorage'
import { useSessionListAutoRefresh } from '../../hooks/useSessionListAutoRefresh'
import { formatRelativeTime } from '../../lib/formatRelativeTime'
const desktopHost = getDesktopHost()
const isDesktopRuntime = desktopHost.isDesktop
@@ -57,18 +73,8 @@ const KBD_CLASS = 'pointer-events-none inline-flex h-[18px] min-w-[18px] shrink-
/** The selected row is a white card lifted off the sidebar ground. */
const SIDEBAR_ROW_RAISED = 'bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)] shadow-[0_0_0_1px_var(--color-border),var(--shadow-raised)]'
const ICON_STROKE = 1.75
const SESSION_LIST_AUTO_REFRESH_MS = 30_000
const SESSION_LIST_BUILDING_REFRESH_MS = 1_500
const SESSION_LIST_FOCUS_REFRESH_MIN_MS = 5_000
const PROJECT_ORDER_STORAGE_KEY = 'cc-haha-sidebar-project-order'
const PROJECT_PINNED_STORAGE_KEY = 'cc-haha-sidebar-pinned-projects'
const PROJECT_HIDDEN_STORAGE_KEY = 'cc-haha-sidebar-hidden-projects'
const PROJECT_ORGANIZATION_STORAGE_KEY = 'cc-haha-sidebar-project-organization'
const PROJECT_SORT_STORAGE_KEY = 'cc-haha-sidebar-project-sort'
const PROJECT_GROUP_VISIBLE_COUNT = SIDEBAR_PROJECT_SESSION_PREVIEW_LIMIT
type SidebarProjectOrganization = 'project' | 'recentProject' | 'time'
type SidebarProjectSortBy = 'createdAt' | 'updatedAt'
type SidebarHeaderMenuType = 'main' | 'organize' | 'sort' | 'create'
type ProjectGroup = {
@@ -92,8 +98,6 @@ type ProjectEditorState =
}
type SidebarProps = {
isMobile?: boolean
onRequestClose?: () => void
desktopUiPreferencesRequest?: Promise<DesktopUiPreferencesResponse> | null
onDesktopUiPreferencesConsumed?: (request: Promise<DesktopUiPreferencesResponse>) => void
}
@@ -117,8 +121,6 @@ function openInFileManagerKey(platform: string | null): TranslationKey {
}
export function Sidebar({
isMobile = false,
onRequestClose,
desktopUiPreferencesRequest,
onDesktopUiPreferencesConsumed,
}: SidebarProps) {
@@ -330,7 +332,7 @@ export function Sidebar({
.filter((session): session is SessionListItem => Boolean(session)),
[pendingBatchDeleteSessionIds, sessionsById],
)
const expanded = isMobile ? true : sidebarOpen
const expanded = sidebarOpen
// Only the packaged app has a version worth showing; the browser host
// answers with a placeholder.
const [appVersion, setAppVersion] = useState<string | null>(null)
@@ -347,9 +349,6 @@ export function Sidebar({
cancelled = true
}
}, [])
const closeMobileDrawer = useCallback(() => {
if (isMobile) onRequestClose?.()
}, [isMobile, onRequestClose])
const applySidebarProjectPreferences = useCallback((preferences: SidebarProjectPreferences) => {
setProjectOrder(preferences.projectOrder)
@@ -487,14 +486,13 @@ export function Sidebar({
restoreHiddenProjectForWorkDir(workDir)
useTabStore.getState().openTab(sessionId, t('sidebar.newSession'))
useChatStore.getState().connectToSession(sessionId)
closeMobileDrawer()
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
})
}
}, [addToast, closeMobileDrawer, restoreHiddenProjectForWorkDir, t])
}, [addToast, restoreHiddenProjectForWorkDir, t])
const openProjectHeaderMenu = useCallback((event: React.MouseEvent, type: SidebarHeaderMenuType) => {
event.stopPropagation()
@@ -637,7 +635,6 @@ export function Sidebar({
useTabStore.getState().openTab(sessionId, t('sidebar.newSession'))
useChatStore.getState().connectToSession(sessionId)
closeMobileDrawer()
closeProjectEditor()
const context = await contextRequest
@@ -657,7 +654,7 @@ export function Sidebar({
} finally {
setProjectEditorLoading(false)
}
}, [addToast, closeMobileDrawer, closeProjectEditor, restoreHiddenProjectForWorkDir, t])
}, [addToast, closeProjectEditor, restoreHiddenProjectForWorkDir, t])
const openProjectEditor = useCallback((project: ProjectGroup) => {
if (project.key === 'unknown' || !project.workDir) return
@@ -823,8 +820,7 @@ export function Sidebar({
}
useSessionStore.getState().openHistoricalSession(session)
useChatStore.getState().connectToSession(session.id)
closeMobileDrawer()
}, [closeMobileDrawer, handleBatchSessionClick, isBatchMode])
}, [handleBatchSessionClick, isBatchMode])
const handleExitBatchMode = useCallback(() => {
exitBatchMode()
@@ -995,7 +991,7 @@ export function Sidebar({
icon={<Bell size={16} strokeWidth={ICON_STROKE} aria-hidden="true" />}
label={t('sidebar.taskView')}
onClick={toggleTaskView}
size={isMobile ? '2xl' : 'sm'}
size="sm"
tone="muted"
pressed={isTaskView}
surface="sidebar"
@@ -1014,28 +1010,16 @@ export function Sidebar({
>
<GitHubIcon />
</a>
{isMobile ? (
<button
type="button"
onClick={closeMobileDrawer}
className="sidebar-toggle-button flex h-11 w-11 items-center justify-center rounded-[var(--radius-md)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]"
aria-label={t('sidebar.collapse')}
title={t('sidebar.collapse')}
>
<X size={18} strokeWidth={ICON_STROKE} aria-hidden="true" />
</button>
) : (
<button
type="button"
onClick={toggleSidebar}
data-testid={expanded ? 'sidebar-collapse-button' : 'sidebar-expand-button'}
className={`sidebar-toggle-button ${expanded ? 'sidebar-toggle-button--open h-7 w-7' : 'sidebar-toggle-button--collapsed h-8 w-8'} flex items-center justify-center rounded-[var(--radius-sm)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]`}
aria-label={expanded ? t('sidebar.collapse') : t('sidebar.expand')}
title={expanded ? t('sidebar.collapse') : t('sidebar.expand')}
>
<SidebarToggleIcon collapsed={!expanded} />
</button>
)}
<button
type="button"
onClick={toggleSidebar}
data-testid={expanded ? 'sidebar-collapse-button' : 'sidebar-expand-button'}
className={`sidebar-toggle-button ${expanded ? 'sidebar-toggle-button--open h-7 w-7' : 'sidebar-toggle-button--collapsed h-8 w-8'} flex items-center justify-center rounded-[var(--radius-sm)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]`}
aria-label={expanded ? t('sidebar.collapse') : t('sidebar.expand')}
title={expanded ? t('sidebar.collapse') : t('sidebar.expand')}
>
<SidebarToggleIcon collapsed={!expanded} />
</button>
</div>
</div>
</div>
@@ -1045,7 +1029,6 @@ export function Sidebar({
active={false}
collapsed={!expanded}
label={t('sidebar.newSession')}
touchFriendly={isMobile}
onClick={() => {
const currentTabId = useTabStore.getState().activeTabId
const currentSession = currentTabId
@@ -1058,36 +1041,28 @@ export function Sidebar({
>
{t('sidebar.newSession')}
</NavItem>
{!isMobile && (
<NavItem
active={activeTabId === SCHEDULED_TAB_ID}
collapsed={!expanded}
label={t('sidebar.scheduled')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled')
closeMobileDrawer()
}}
icon={<CalendarClock size={16} strokeWidth={ICON_STROKE} aria-hidden="true" />}
>
{t('sidebar.scheduled')}
</NavItem>
)}
{!isMobile && (
<NavItem
active={activeTabId === MARKET_TAB_ID || activeTabId === CONNECTORS_TAB_ID}
collapsed={!expanded}
label={t('sidebar.extensions')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTab(MARKET_TAB_ID, t('sidebar.extensions'), 'market')
closeMobileDrawer()
}}
icon={<Store size={16} strokeWidth={ICON_STROKE} aria-hidden="true" />}
>
{t('sidebar.extensions')}
</NavItem>
)}
<NavItem
active={activeTabId === SCHEDULED_TAB_ID}
collapsed={!expanded}
label={t('sidebar.scheduled')}
onClick={() => {
useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled')
}}
icon={<CalendarClock size={16} strokeWidth={ICON_STROKE} aria-hidden="true" />}
>
{t('sidebar.scheduled')}
</NavItem>
<NavItem
active={activeTabId === MARKET_TAB_ID || activeTabId === CONNECTORS_TAB_ID}
collapsed={!expanded}
label={t('sidebar.extensions')}
onClick={() => {
useTabStore.getState().openTab(MARKET_TAB_ID, t('sidebar.extensions'), 'market')
}}
icon={<Store size={16} strokeWidth={ICON_STROKE} aria-hidden="true" />}
>
{t('sidebar.extensions')}
</NavItem>
</div>
@@ -1101,13 +1076,13 @@ export function Sidebar({
<button
type="button"
onClick={() => openModal('globalSearch')}
className={`flex w-full min-w-0 items-center gap-2 rounded-[var(--radius-sm)] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-2 pr-1.5 text-left text-[13px] text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-outline)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)] ${isMobile ? 'h-11' : 'h-[30px]'}`}
className={`flex w-full min-w-0 items-center gap-2 rounded-[var(--radius-sm)] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-2 pr-1.5 text-left text-[13px] text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-outline)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)] h-[30px]`}
aria-label={t('search.global.trigger')}
title={t('search.global.trigger')}
>
<Search size={14} strokeWidth={ICON_STROKE} className="pointer-events-none shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{t('search.global.trigger')}</span>
{!isMobile && <kbd className={KBD_CLASS}>{MOD_KEY}K</kbd>}
<kbd className={KBD_CLASS}>{MOD_KEY}K</kbd>
</button>
</div>
@@ -1167,7 +1142,7 @@ export function Sidebar({
two full-size bordered buttons, which made them read as peers
of search. They are list maintenance, so they live with the
list: in this section header, revealed on hover like its other
actions and always shown in the touch drawer. The header stays
actions. The header stays
rendered while the first load is in flight, so a stuck load can
still be retried by hand. */}
<ProjectHeaderActions
@@ -1183,7 +1158,6 @@ export function Sidebar({
onOpenMenu={(event) => openProjectHeaderMenu(event, 'main')}
onOpenCreate={(event) => openProjectHeaderMenu(event, 'create')}
actionsRef={projectHeaderActionsRef}
isMobile={isMobile}
/>
{showInitialLoading ? (
<div className="px-2 py-4 text-center text-[12px] text-[var(--color-text-tertiary)]">
@@ -1202,7 +1176,6 @@ export function Sidebar({
attentionSessionIds={attentionSessionIds}
selectedSessionIds={selectedSessionIds}
isBatchMode={isBatchMode}
isMobile={isMobile}
renamingId={renamingId}
renameValue={renameValue}
workspaceLabelFor={workspaceLabelFor}
@@ -1257,7 +1230,7 @@ export function Sidebar({
onDragEnd={clearProjectDragState}
onClick={() => toggleProjectCollapsed(project.key)}
data-state={projectCollapsed ? 'closed' : 'open'}
className={`flex min-w-0 flex-1 cursor-grab items-center gap-2 rounded-[var(--radius-sm)] px-2 text-left transition-colors active:cursor-grabbing hover:bg-[var(--color-sidebar-item-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${isMobile ? 'min-h-11' : 'h-7'}`}
className={`flex min-w-0 flex-1 cursor-grab items-center gap-2 rounded-[var(--radius-sm)] px-2 text-left transition-colors active:cursor-grabbing hover:bg-[var(--color-sidebar-item-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] h-7`}
aria-expanded={!projectCollapsed}
aria-label={t(projectCollapsed ? 'sidebar.expandProject' : 'sidebar.collapseProject', { project: project.title })}
title={project.subtitle || project.title}
@@ -1284,9 +1257,7 @@ export function Sidebar({
{!isBatchMode && (
<span
data-testid={`sidebar-project-count-${domSafeProjectKey(project.key)}`}
className={`flex-shrink-0 text-[11px] font-medium tabular-nums text-[var(--color-text-tertiary)] ${
isMobile ? '' : 'group-hover/project:hidden group-focus-within/project:hidden'
}`}
className="flex-shrink-0 text-[11px] font-medium tabular-nums text-[var(--color-text-tertiary)] group-hover/project:hidden group-focus-within/project:hidden"
>
{projectSessionTotal ?? project.sessions.length}
</span>
@@ -1310,18 +1281,10 @@ export function Sidebar({
</button>
)}
{!isBatchMode && (
// Desktop reveals these on row hover, in place of the
// session count. At rest they take no width (clipped,
// still focusable), so the title keeps the whole row.
// The touch drawer has neither hover nor a way to focus
// through `pointer-events: none`, so there they stay
// put — two 44px targets with enough gap not to catch
// each other.
<div className={`flex items-center transition-opacity duration-150 ${
isMobile
? 'gap-1.5 opacity-100'
: 'pointer-events-none max-w-0 gap-0.5 overflow-hidden opacity-0 group-hover/project:pointer-events-auto group-hover/project:max-w-none group-hover/project:overflow-visible group-hover/project:opacity-100 group-focus-within/project:pointer-events-auto group-focus-within/project:max-w-none group-focus-within/project:overflow-visible group-focus-within/project:opacity-100'
}`}>
// Revealed on row hover, in place of the session
// count. At rest they take no width (clipped, still
// focusable), so the title keeps the whole row.
<div className="flex items-center pointer-events-none max-w-0 gap-0.5 overflow-hidden opacity-0 transition-opacity duration-150 group-hover/project:pointer-events-auto group-hover/project:max-w-none group-hover/project:overflow-visible group-hover/project:opacity-100 group-focus-within/project:pointer-events-auto group-focus-within/project:max-w-none group-focus-within/project:overflow-visible group-focus-within/project:opacity-100">
<IconButton
icon={<Ellipsis size={16} strokeWidth={ICON_STROKE} aria-hidden="true" />}
label={t('sidebar.projectActions', { project: project.title })}
@@ -1330,7 +1293,7 @@ export function Sidebar({
setContextMenu(null)
setProjectContextMenu({ key: project.key, x: event.clientX, y: event.clientY })
}}
size={isMobile ? '2xl' : 'sm'}
size="sm"
tone="muted"
surface="sidebar"
/>
@@ -1341,7 +1304,7 @@ export function Sidebar({
event.stopPropagation()
void createSessionForWorkDir(project.workDir)
}}
size={isMobile ? '2xl' : 'sm'}
size="sm"
tone="muted"
surface="sidebar"
/>
@@ -1382,7 +1345,7 @@ export function Sidebar({
if (e.key === 'Enter') handleFinishRename()
if (e.key === 'Escape') cancelRename()
}}
className={`w-full rounded-[var(--radius-sm)] border border-[var(--color-border-focus)] bg-[var(--color-surface)] pl-[31px] pr-2 text-[13px] text-[var(--color-text-primary)] outline-none ${isMobile ? 'h-11' : 'h-[30px]'}`}
className={`w-full rounded-[var(--radius-sm)] border border-[var(--color-border-focus)] bg-[var(--color-surface)] pl-[31px] pr-2 text-[13px] text-[var(--color-text-primary)] outline-none h-[30px]`}
/>
) : (
<button
@@ -1392,7 +1355,7 @@ export function Sidebar({
// line (8px inset + 16px folder + 8px gap), while
// the row's own fill still spans the full width.
className={`
group/session flex w-full items-center rounded-[var(--radius-sm)] pl-8 pr-2 ${isMobile ? 'min-h-11' : 'h-[30px]'} text-left text-[13px] transition-[background,color,box-shadow] duration-150
group/session flex w-full items-center rounded-[var(--radius-sm)] pl-8 pr-2 h-[30px] text-left text-[13px] transition-[background,color,box-shadow] duration-150
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--color-surface-sidebar)]
${selectedSessionIds.has(session.id)
? `sidebar-session-row--selected ${SIDEBAR_ROW_RAISED} font-medium`
@@ -1450,7 +1413,7 @@ export function Sidebar({
<button
type="button"
onClick={() => toggleProjectSessionExpansion(project.key)}
className={`inline-flex items-center justify-start rounded-[var(--radius-sm)] pl-8 pr-2 text-[12px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${isMobile ? 'min-h-11' : 'h-7'}`}
className={`inline-flex items-center justify-start rounded-[var(--radius-sm)] pl-8 pr-2 text-[12px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] h-7`}
aria-expanded={sessionsExpanded}
>
{sessionsExpanded
@@ -1484,10 +1447,8 @@ export function Sidebar({
active={activeTabId === SETTINGS_TAB_ID}
collapsed={!expanded}
label={t('sidebar.settings')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
closeMobileDrawer()
}}
icon={<Settings size={16} strokeWidth={ICON_STROKE} aria-hidden="true" />}
>
@@ -1705,66 +1666,6 @@ export function Sidebar({
)
}
function useSessionListAutoRefresh(
fetchSessions: () => Promise<void>,
indexBuilding = false,
): () => Promise<void> {
const inFlightRef = useRef<Promise<void> | null>(null)
const lastStartedAtRef = useRef(0)
const minIntervalMs = indexBuilding
? SESSION_LIST_BUILDING_REFRESH_MS
: SESSION_LIST_FOCUS_REFRESH_MIN_MS
const refreshSessions = useCallback((force = false) => {
if (inFlightRef.current && !force) return inFlightRef.current
const now = Date.now()
if (!force && now - lastStartedAtRef.current < minIntervalMs) {
return Promise.resolve()
}
lastStartedAtRef.current = now
const request = Promise.resolve()
.then(() => fetchSessions())
.catch(() => undefined)
.finally(() => {
if (inFlightRef.current === request) {
inFlightRef.current = null
}
})
inFlightRef.current = request
return request
}, [fetchSessions, minIntervalMs])
useEffect(() => {
void refreshSessions(true)
const refreshIfVisible = () => {
if (!isDocumentVisible()) return
void refreshSessions()
}
window.addEventListener('focus', refreshIfVisible)
document.addEventListener('visibilitychange', refreshIfVisible)
const timer = window.setInterval(() => {
if (!isDocumentVisible()) return
void refreshSessions()
}, indexBuilding ? SESSION_LIST_BUILDING_REFRESH_MS : SESSION_LIST_AUTO_REFRESH_MS)
return () => {
window.removeEventListener('focus', refreshIfVisible)
document.removeEventListener('visibilitychange', refreshIfVisible)
window.clearInterval(timer)
}
}, [refreshSessions, indexBuilding])
return useCallback(() => refreshSessions(true), [refreshSessions])
}
function isDocumentVisible(): boolean {
return typeof document === 'undefined' || document.visibilityState !== 'hidden'
}
function readFirstVisibleSessionAnchor(scrollArea: HTMLElement): SessionScrollAnchor | null {
const scrollRect = scrollArea.getBoundingClientRect()
const rows = scrollArea.querySelectorAll<HTMLElement>('[data-sidebar-session-id]')
@@ -1802,7 +1703,6 @@ function ProjectHeaderActions({
onOpenMenu,
onOpenCreate,
actionsRef,
isMobile = false,
}: {
title: string
menuLabel: string
@@ -1817,29 +1717,22 @@ function ProjectHeaderActions({
onOpenCreate: (event: React.MouseEvent) => void
/** Handed to `useDismissable` as the trigger, so opening does not self-close. */
actionsRef: React.RefObject<HTMLDivElement>
isMobile?: boolean
}) {
const size = isMobile ? '2xl' : 'xs'
const size = 'xs'
return (
<div
data-testid="sidebar-projects-header"
className={`group/sidebar-projects flex items-center justify-between pl-2 pr-0.5 ${isMobile ? 'pb-1' : 'h-7'}`}
className="group/sidebar-projects flex h-7 items-center justify-between pl-2 pr-0.5"
>
<div className="text-[12px] font-semibold text-[var(--color-text-tertiary)]">
{title}
</div>
{/* Hover-revealed on desktop — anywhere over the list, not only this
row — and held open while batch mode is on, so the pressed toggle
that ends it stays in sight. A touch drawer has no hover, and these
kept `pointer-events`, so on the phone they were invisible but still
tappable — a blind target. */}
{/* Hover-revealed anywhere over the list, not only this row — and held
open while batch mode is on, so the pressed toggle that ends it
stays in sight. */}
<div
ref={actionsRef}
className={`flex items-center transition-opacity focus-within:opacity-100 ${
isMobile
? 'gap-1.5 opacity-100'
: `gap-0.5 ${batchActive ? 'opacity-100' : 'opacity-0'} group-hover/sidebar-projects:opacity-100 group-hover/sidebar-list:opacity-100`
}`}
className={`flex items-center gap-0.5 transition-opacity focus-within:opacity-100 ${batchActive ? 'opacity-100' : 'opacity-0'} group-hover/sidebar-projects:opacity-100 group-hover/sidebar-list:opacity-100`}
>
<IconButton
icon={<RefreshCw size={14} strokeWidth={ICON_STROKE} className={refreshing ? 'animate-spin' : undefined} aria-hidden="true" />}
@@ -2102,150 +1995,6 @@ function getProjectDropPosition(event: React.DragEvent<HTMLElement>): 'before' |
return event.clientY <= rect.top + rect.height / 2 ? 'before' : 'after'
}
function readStoredProjectOrder(): string[] {
if (typeof localStorage === 'undefined') return []
try {
const parsed = JSON.parse(localStorage.getItem(PROJECT_ORDER_STORAGE_KEY) ?? '[]')
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []
} catch {
return []
}
}
function writeStoredProjectOrder(projectOrder: string[]): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(PROJECT_ORDER_STORAGE_KEY, JSON.stringify(projectOrder))
} catch {
// Sidebar ordering is a UI preference; ignore storage failures.
}
}
function readStoredProjectPins(): Set<string> {
if (typeof localStorage === 'undefined') return new Set()
try {
const parsed = JSON.parse(localStorage.getItem(PROJECT_PINNED_STORAGE_KEY) ?? '[]')
return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [])
} catch {
return new Set()
}
}
function writeStoredProjectPins(projectKeys: Set<string>): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(PROJECT_PINNED_STORAGE_KEY, JSON.stringify([...projectKeys]))
} catch {
// Sidebar pinning is a UI preference; ignore storage failures.
}
}
function readStoredProjectHidden(): Set<string> {
if (typeof localStorage === 'undefined') return new Set()
try {
const parsed = JSON.parse(localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')
return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [])
} catch {
return new Set()
}
}
function writeStoredProjectHidden(projectKeys: Set<string>): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify([...projectKeys]))
} catch {
// Hidden projects are a local UI preference; ignore storage failures.
}
}
function readStoredProjectOrganization(): SidebarProjectOrganization {
if (typeof localStorage === 'undefined') return 'recentProject'
return normalizeProjectOrganization(localStorage.getItem(PROJECT_ORGANIZATION_STORAGE_KEY))
}
function writeStoredProjectOrganization(organization: SidebarProjectOrganization): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(PROJECT_ORGANIZATION_STORAGE_KEY, organization)
} catch {
// Sidebar organization is a UI preference; ignore storage failures.
}
}
function readStoredProjectSortBy(): SidebarProjectSortBy {
if (typeof localStorage === 'undefined') return 'updatedAt'
return normalizeProjectSortBy(localStorage.getItem(PROJECT_SORT_STORAGE_KEY))
}
function writeStoredProjectSortBy(sortBy: SidebarProjectSortBy): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(PROJECT_SORT_STORAGE_KEY, sortBy)
} catch {
// Sidebar sorting is a UI preference; ignore storage failures.
}
}
function buildSidebarProjectPreferences(
projectOrder: string[],
pinnedProjectKeys: Set<string>,
hiddenProjectKeys: Set<string>,
projectOrganization: SidebarProjectOrganization,
projectSortBy: SidebarProjectSortBy,
): SidebarProjectPreferences {
return normalizeSidebarProjectPreferences({
projectOrder,
pinnedProjects: [...pinnedProjectKeys],
hiddenProjects: [...hiddenProjectKeys],
projectOrganization,
projectSortBy,
})
}
function readCachedSidebarProjectPreferences(): SidebarProjectPreferences {
return {
projectOrder: readStoredProjectOrder(),
pinnedProjects: [...readStoredProjectPins()],
hiddenProjects: [...readStoredProjectHidden()],
projectOrganization: readStoredProjectOrganization(),
projectSortBy: readStoredProjectSortBy(),
}
}
function writeCachedSidebarProjectPreferences(preferences: SidebarProjectPreferences): void {
const normalized = normalizeSidebarProjectPreferences(preferences)
writeStoredProjectOrder(normalized.projectOrder)
writeStoredProjectPins(new Set(normalized.pinnedProjects))
writeStoredProjectHidden(new Set(normalized.hiddenProjects))
writeStoredProjectOrganization(normalized.projectOrganization)
writeStoredProjectSortBy(normalized.projectSortBy)
}
function normalizeSidebarProjectPreferences(preferences: Partial<SidebarProjectPreferences> | undefined): SidebarProjectPreferences {
return {
projectOrder: normalizeProjectKeyList(preferences?.projectOrder),
pinnedProjects: normalizeProjectKeyList(preferences?.pinnedProjects),
hiddenProjects: normalizeProjectKeyList(preferences?.hiddenProjects),
projectOrganization: normalizeProjectOrganization(preferences?.projectOrganization),
projectSortBy: normalizeProjectSortBy(preferences?.projectSortBy),
}
}
function normalizeProjectKeyList(values: unknown): string[] {
if (!Array.isArray(values)) return []
const seen = new Set<string>()
const normalized: string[] = []
for (const value of values) {
if (typeof value !== 'string' || value.length === 0 || seen.has(value)) continue
seen.add(value)
normalized.push(value)
}
return normalized
}
function normalizeProjectPathForComparison(value: string): string {
const normalized = value.replace(/\\/g, '/').replace(/\/+$/g, '') || value
return isWindows ? normalized.toLowerCase() : normalized
@@ -2264,22 +2013,6 @@ function projectPathMatches(projectKey: string, workDir: string): boolean {
return normalizedWorkDir.startsWith(`${normalizedProjectKey}/`)
}
function hasSidebarProjectPreferences(preferences: SidebarProjectPreferences): boolean {
return preferences.projectOrder.length > 0
|| preferences.pinnedProjects.length > 0
|| preferences.hiddenProjects.length > 0
|| preferences.projectOrganization !== 'recentProject'
|| preferences.projectSortBy !== 'updatedAt'
}
function normalizeProjectOrganization(value: unknown): SidebarProjectOrganization {
return value === 'project' || value === 'recentProject' || value === 'time' ? value : 'recentProject'
}
function normalizeProjectSortBy(value: unknown): SidebarProjectSortBy {
return value === 'createdAt' || value === 'updatedAt' ? value : 'updatedAt'
}
function getVisibleProjectSessions(
sessions: SessionListItem[],
expanded: boolean,
@@ -2447,7 +2180,6 @@ function NavItem({
active,
collapsed,
label,
touchFriendly,
onClick,
icon,
shortcut,
@@ -2456,7 +2188,6 @@ function NavItem({
active: boolean
collapsed: boolean
label: string
touchFriendly?: boolean
onClick: () => void
icon: React.ReactNode
/** Shown as a kbd on hover; desktop only. */
@@ -2471,7 +2202,7 @@ function NavItem({
className={`
group/nav flex items-center text-[13px] transition-colors duration-150
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]
${collapsed ? 'h-9 w-9 justify-center rounded-[var(--radius-md)] px-0 py-0' : `w-full min-w-0 gap-2.5 rounded-[var(--radius-sm)] px-2 ${touchFriendly ? 'min-h-11' : 'h-[30px]'}`}
${collapsed ? 'h-9 w-9 justify-center rounded-[var(--radius-md)] px-0 py-0' : `w-full min-w-0 gap-2.5 rounded-[var(--radius-sm)] px-2 h-[30px]`}
${active
? `${SIDEBAR_ROW_RAISED} font-medium`
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'
@@ -2486,32 +2217,13 @@ function NavItem({
<span className={`sidebar-copy min-w-0 flex-1 text-left ${collapsed ? 'sidebar-copy--hidden' : 'sidebar-copy--visible'}`}>
{children}
</span>
{shortcut && !collapsed && !touchFriendly ? (
{shortcut && !collapsed ? (
<kbd className={`${KBD_CLASS} opacity-0 transition-opacity group-hover/nav:opacity-100 group-focus-visible/nav:opacity-100`}>{shortcut}</kbd>
) : null}
</button>
)
}
function formatRelativeTime(
dateStr: string,
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
): string {
const date = new Date(dateStr)
const timestamp = date.getTime()
if (!Number.isFinite(timestamp)) return ''
const diff = Date.now() - timestamp
const min = Math.floor(diff / 60000)
if (min < 1) return t('session.timeJustNow')
if (min < 60) return t('session.timeMinutes', { n: min })
const hr = Math.floor(min / 60)
if (hr < 24) return t('session.timeHours', { n: hr })
const day = Math.floor(hr / 24)
if (day < 30) return t('session.timeDays', { n: day })
return new Intl.DateTimeFormat(undefined, { month: 'numeric', day: 'numeric' }).format(date)
}
function GitHubIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
@@ -28,7 +28,6 @@ export type SidebarTaskListProps = {
attentionSessionIds: ReadonlySet<string>
selectedSessionIds: ReadonlySet<string>
isBatchMode: boolean
isMobile: boolean
renamingId: string | null
renameValue: string
workspaceLabelFor: (session: SessionListItem) => string
@@ -47,7 +46,6 @@ export function SidebarTaskList({
attentionSessionIds,
selectedSessionIds,
isBatchMode,
isMobile,
renamingId,
renameValue,
workspaceLabelFor,
@@ -98,7 +96,6 @@ export function SidebarTaskList({
isRunning={runningSessionIds.has(session.id)}
needsAttention={attentionSessionIds.has(session.id)}
isBatchMode={isBatchMode}
isMobile={isMobile}
workspaceLabel={workspaceLabelFor(session)}
onClick={onSessionClick}
onContextMenu={onSessionContextMenu}
@@ -121,7 +118,6 @@ function SidebarTaskRow({
isRunning,
needsAttention,
isBatchMode,
isMobile,
workspaceLabel,
onClick,
onContextMenu,
@@ -133,7 +129,6 @@ function SidebarTaskRow({
isRunning: boolean
needsAttention: boolean
isBatchMode: boolean
isMobile: boolean
workspaceLabel: string
onClick: (event: React.MouseEvent, session: SessionListItem) => void
onContextMenu: (event: React.MouseEvent, sessionId: string) => void
@@ -148,7 +143,7 @@ function SidebarTaskRow({
onClick={(event) => onClick(event, session)}
onContextMenu={(event) => onContextMenu(event, session.id)}
className={`
group/session w-full rounded-[var(--radius-sm)] px-2 ${isMobile ? 'py-2.5' : 'py-1.5'} text-left transition-[background,color,box-shadow] duration-150
group/session w-full rounded-[var(--radius-sm)] px-2 py-1.5 text-left transition-[background,color,box-shadow] duration-150
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--color-surface-sidebar)]
${isSelected
? 'sidebar-session-row--selected bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)] shadow-[0_0_0_1px_var(--color-border),var(--shadow-raised)]'
@@ -0,0 +1,179 @@
import type { SidebarProjectPreferences } from '../../api/desktopUiPreferences'
/**
* The sidebar's project preferences (order, pins, hidden projects, grouping,
* sort) as cached in this browser. The server copy in desktop UI preferences is
* the source of truth; this cache is what a list shows before that answer
* arrives. The desktop sidebar writes it, and the phone's session list reads
* the hidden projects from it so a project hidden on the desktop stays hidden
* on the phone.
*/
export type SidebarProjectOrganization = 'project' | 'recentProject' | 'time'
export type SidebarProjectSortBy = 'createdAt' | 'updatedAt'
const PROJECT_ORDER_STORAGE_KEY = 'cc-haha-sidebar-project-order'
const PROJECT_PINNED_STORAGE_KEY = 'cc-haha-sidebar-pinned-projects'
const PROJECT_HIDDEN_STORAGE_KEY = 'cc-haha-sidebar-hidden-projects'
const PROJECT_ORGANIZATION_STORAGE_KEY = 'cc-haha-sidebar-project-organization'
const PROJECT_SORT_STORAGE_KEY = 'cc-haha-sidebar-project-sort'
export function readStoredProjectOrder(): string[] {
if (typeof localStorage === 'undefined') return []
try {
const parsed = JSON.parse(localStorage.getItem(PROJECT_ORDER_STORAGE_KEY) ?? '[]')
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []
} catch {
return []
}
}
export function writeStoredProjectOrder(projectOrder: string[]): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(PROJECT_ORDER_STORAGE_KEY, JSON.stringify(projectOrder))
} catch {
// Sidebar ordering is a UI preference; ignore storage failures.
}
}
export function readStoredProjectPins(): Set<string> {
if (typeof localStorage === 'undefined') return new Set()
try {
const parsed = JSON.parse(localStorage.getItem(PROJECT_PINNED_STORAGE_KEY) ?? '[]')
return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [])
} catch {
return new Set()
}
}
export function writeStoredProjectPins(projectKeys: Set<string>): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(PROJECT_PINNED_STORAGE_KEY, JSON.stringify([...projectKeys]))
} catch {
// Sidebar pinning is a UI preference; ignore storage failures.
}
}
export function readStoredProjectHidden(): Set<string> {
if (typeof localStorage === 'undefined') return new Set()
try {
const parsed = JSON.parse(localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')
return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [])
} catch {
return new Set()
}
}
export function writeStoredProjectHidden(projectKeys: Set<string>): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify([...projectKeys]))
} catch {
// Hidden projects are a local UI preference; ignore storage failures.
}
}
export function readStoredProjectOrganization(): SidebarProjectOrganization {
if (typeof localStorage === 'undefined') return 'recentProject'
return normalizeProjectOrganization(localStorage.getItem(PROJECT_ORGANIZATION_STORAGE_KEY))
}
export function writeStoredProjectOrganization(organization: SidebarProjectOrganization): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(PROJECT_ORGANIZATION_STORAGE_KEY, organization)
} catch {
// Sidebar organization is a UI preference; ignore storage failures.
}
}
export function readStoredProjectSortBy(): SidebarProjectSortBy {
if (typeof localStorage === 'undefined') return 'updatedAt'
return normalizeProjectSortBy(localStorage.getItem(PROJECT_SORT_STORAGE_KEY))
}
export function writeStoredProjectSortBy(sortBy: SidebarProjectSortBy): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(PROJECT_SORT_STORAGE_KEY, sortBy)
} catch {
// Sidebar sorting is a UI preference; ignore storage failures.
}
}
export function buildSidebarProjectPreferences(
projectOrder: string[],
pinnedProjectKeys: Set<string>,
hiddenProjectKeys: Set<string>,
projectOrganization: SidebarProjectOrganization,
projectSortBy: SidebarProjectSortBy,
): SidebarProjectPreferences {
return normalizeSidebarProjectPreferences({
projectOrder,
pinnedProjects: [...pinnedProjectKeys],
hiddenProjects: [...hiddenProjectKeys],
projectOrganization,
projectSortBy,
})
}
export function readCachedSidebarProjectPreferences(): SidebarProjectPreferences {
return {
projectOrder: readStoredProjectOrder(),
pinnedProjects: [...readStoredProjectPins()],
hiddenProjects: [...readStoredProjectHidden()],
projectOrganization: readStoredProjectOrganization(),
projectSortBy: readStoredProjectSortBy(),
}
}
export function writeCachedSidebarProjectPreferences(preferences: SidebarProjectPreferences): void {
const normalized = normalizeSidebarProjectPreferences(preferences)
writeStoredProjectOrder(normalized.projectOrder)
writeStoredProjectPins(new Set(normalized.pinnedProjects))
writeStoredProjectHidden(new Set(normalized.hiddenProjects))
writeStoredProjectOrganization(normalized.projectOrganization)
writeStoredProjectSortBy(normalized.projectSortBy)
}
export function normalizeSidebarProjectPreferences(preferences: Partial<SidebarProjectPreferences> | undefined): SidebarProjectPreferences {
return {
projectOrder: normalizeProjectKeyList(preferences?.projectOrder),
pinnedProjects: normalizeProjectKeyList(preferences?.pinnedProjects),
hiddenProjects: normalizeProjectKeyList(preferences?.hiddenProjects),
projectOrganization: normalizeProjectOrganization(preferences?.projectOrganization),
projectSortBy: normalizeProjectSortBy(preferences?.projectSortBy),
}
}
export function normalizeProjectKeyList(values: unknown): string[] {
if (!Array.isArray(values)) return []
const seen = new Set<string>()
const normalized: string[] = []
for (const value of values) {
if (typeof value !== 'string' || value.length === 0 || seen.has(value)) continue
seen.add(value)
normalized.push(value)
}
return normalized
}
export function hasSidebarProjectPreferences(preferences: SidebarProjectPreferences): boolean {
return preferences.projectOrder.length > 0
|| preferences.pinnedProjects.length > 0
|| preferences.hiddenProjects.length > 0
|| preferences.projectOrganization !== 'recentProject'
|| preferences.projectSortBy !== 'updatedAt'
}
export function normalizeProjectOrganization(value: unknown): SidebarProjectOrganization {
return value === 'project' || value === 'recentProject' || value === 'time' ? value : 'recentProject'
}
export function normalizeProjectSortBy(value: unknown): SidebarProjectSortBy {
return value === 'createdAt' || value === 'updatedAt' ? value : 'updatedAt'
}
@@ -0,0 +1,49 @@
import { ListChecks } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Spinner } from '@/components/ui/Spinner'
import { useActivityPanelStore } from '../../stores/activityPanelStore'
/**
* The phone's way into a session's parallel work — subagents, background
* tasks, team members, the task list. The desktop reaches it from the tab
* strip; on a phone it is this pill in the top bar, which says how much needs
* a look and opens the activity sheet. It only appears once there is
* something to show.
*/
export function MobileActivityPill({ sessionId }: { sessionId: string }) {
const t = useTranslation()
const summary = useActivityPanelStore((state) => state.mobileSummaryBySession[sessionId])
const isOpen = useActivityPanelStore((state) => state.isOpen(sessionId))
const open = useActivityPanelStore((state) => state.open)
if (!summary?.visible) return null
const busy = summary.count > 0
return (
<button
type="button"
data-testid="mobile-activity-pill"
data-session-activity-trigger="true"
aria-haspopup="dialog"
aria-expanded={isOpen}
onClick={() => open(sessionId)}
className={`flex h-11 shrink-0 items-center gap-1.5 rounded-[var(--radius-full)] px-3 text-[12px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
busy
? 'text-[var(--color-on-info-container)]'
: 'text-[var(--color-text-secondary)]'
}`}
>
<span
className={`flex h-8 items-center gap-1.5 rounded-[var(--radius-full)] border px-2.5 ${
busy
? 'border-[var(--color-info)] bg-[var(--color-info-container)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}
>
{busy
? <Spinner size={12} />
: <ListChecks size={14} strokeWidth={1.75} aria-hidden="true" />}
{busy ? t('mobile.activity.inProgress', { count: summary.count }) : t('session.activity.title')}
</span>
</button>
)
}
@@ -0,0 +1,251 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Search, Settings, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { EmptyState } from '@/components/ui/EmptyState'
import { ErrorState } from '@/components/ui/ErrorState'
import { IconButton } from '@/components/ui/IconButton'
import { SearchField } from '@/components/ui/SearchField'
import { SkeletonRows } from '@/components/ui/Skeleton'
import type { DesktopUiPreferencesResponse } from '../../api/desktopUiPreferences'
import { useSessionListAutoRefresh } from '../../hooks/useSessionListAutoRefresh'
import { useChatStore } from '../../stores/chatStore'
import {
resolveProjectDisplayName,
useProjectDisplayNameRevision,
} from '../../stores/projectDisplayNameStore'
import { useSessionStore } from '../../stores/sessionStore'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import type { SessionListItem } from '../../types/session'
import {
normalizeSidebarProjectPreferences,
readCachedSidebarProjectPreferences,
} from '../layout/sidebarProjectPreferenceStorage'
import { getSessionProjectKey, getSessionWorkspaceLabel } from '../layout/sidebarTaskGroups'
import { MobileSessionList } from './MobileSessionList'
import { openMobileSession } from './mobileNavigation'
import {
buildMobileSessionGroups,
filterMobileSessions,
listMobileProjectFilters,
} from './mobileSessionGroups'
import { useMobileSessionStatus } from './mobileSessionStatus'
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.
*/
variant: 'home' | 'pane'
selectedSessionId?: string | null
preferencesRequest?: Promise<DesktopUiPreferencesResponse> | null
}
/**
* Header, search, project filter and the status-grouped session list: what
* the phone opens on, and what the tablet keeps on its left.
*/
export function MobileSessionBrowser({ variant, selectedSessionId = null, preferencesRequest = null }: Props) {
const t = useTranslation()
const sessions = useSessionStore((state) => state.sessions)
const isLoading = useSessionStore((state) => state.isLoading)
const error = useSessionStore((state) => state.error)
const indexStatus = useSessionStore((state) => state.indexStatus)
const fetchSessions = useSessionStore((state) => state.fetchSessions)
const tabs = useTabStore((state) => state.tabs)
const chatSessions = useChatStore((state) => state.sessions)
const projectDisplayNameRevision = useProjectDisplayNameRevision()
const refresh = useSessionListAutoRefresh(
fetchSessions,
indexStatus?.mode === 'on' && indexStatus.state === 'building',
)
const hiddenProjectKeys = useHiddenProjectKeys(preferencesRequest)
const live = useLiveSessionActivity()
const { runningIds, attentionIds } = useMobileSessionStatus(tabs, chatSessions, live)
const [searchOpen, setSearchOpen] = useState(false)
const [query, setQuery] = useState('')
const [projectKey, setProjectKey] = useState<string | null>(null)
const searchRef = useRef<HTMLInputElement>(null)
const workspaceLabelFor = useCallback(
(session: SessionListItem) => getSessionWorkspaceLabel(session, resolveProjectDisplayName),
// A renamed project must relabel its rows.
// eslint-disable-next-line react-hooks/exhaustive-deps
[projectDisplayNameRevision],
)
const visibleSessions = useMemo(
() => hiddenProjectKeys.size === 0
? sessions
: sessions.filter((session) => !hiddenProjectKeys.has(getSessionProjectKey(session))),
[hiddenProjectKeys, sessions],
)
const projectFilters = useMemo(
() => listMobileProjectFilters(visibleSessions, workspaceLabelFor),
[visibleSessions, workspaceLabelFor],
)
const effectiveProjectKey = projectKey && projectFilters.some((filter) => filter.key === projectKey)
? projectKey
: null
const filteredSessions = useMemo(
() => filterMobileSessions(visibleSessions, { projectKey: effectiveProjectKey, query }),
[effectiveProjectKey, query, visibleSessions],
)
const groups = useMemo(
() => buildMobileSessionGroups(filteredSessions, runningIds, attentionIds, Date.now()),
[attentionIds, filteredSessions, runningIds],
)
useEffect(() => {
if (searchOpen) searchRef.current?.focus()
}, [searchOpen])
const closeSearch = () => {
setSearchOpen(false)
setQuery('')
}
const openSettings = () => {
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
}
const filtering = Boolean(query.trim()) || effectiveProjectKey !== null
const showInitialLoading = isLoading && sessions.length === 0
return (
<div
data-testid={`mobile-session-browser-${variant}`}
className="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)]">
{t('mobile.home.title')}
</h1>
<IconButton
size="2xl"
tone="secondary"
icon={searchOpen
? <X size={20} strokeWidth={1.75} aria-hidden="true" />
: <Search size={20} strokeWidth={1.75} aria-hidden="true" />}
label={searchOpen ? t('mobile.home.closeSearch') : t('mobile.home.openSearch')}
aria-expanded={searchOpen}
onClick={() => (searchOpen ? closeSearch() : setSearchOpen(true))}
/>
<IconButton
size="2xl"
tone="secondary"
icon={<Settings size={20} strokeWidth={1.75} aria-hidden="true" />}
label={t('sidebar.settings')}
onClick={openSettings}
/>
</header>
{searchOpen ? (
<div className="shrink-0 px-4 pb-2">
<SearchField
ref={searchRef}
size="xl"
label={t('mobile.home.openSearch')}
clearLabel={t('common.clearSearch')}
placeholder={t('sidebar.searchPlaceholder')}
value={query}
onChange={setQuery}
/>
</div>
) : null}
{projectFilters.length > 1 ? (
<div
role="group"
aria-label={t('mobile.home.projectFilter')}
className="flex shrink-0 gap-2 overflow-x-auto px-4 pb-2 [scrollbar-width:none]"
>
<FilterChip active={effectiveProjectKey === null} onClick={() => setProjectKey(null)}>
{t('mobile.home.allProjects')}
</FilterChip>
{projectFilters.map((filter) => (
<FilterChip
key={filter.key}
active={effectiveProjectKey === filter.key}
onClick={() => setProjectKey(filter.key)}
>
{filter.label}
</FilterChip>
))}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{showInitialLoading ? (
<div className="px-4 pt-2"><SkeletonRows count={6} divided label={t('common.loading')} /></div>
) : error && sessions.length === 0 ? (
<ErrorState
title={t('sidebar.sessionListFailed')}
retryLabel={t('common.retry')}
onRetry={() => void refresh()}
/>
) : groups.length === 0 ? (
<EmptyState
title={filtering
? t('mobile.home.noMatches')
: t(variant === 'home' ? 'mobile.home.empty' : 'mobile.home.emptyPane')}
/>
) : (
<MobileSessionList
groups={groups}
runningIds={runningIds}
attentionIds={attentionIds}
selectedSessionId={variant === 'pane' ? selectedSessionId : null}
workspaceLabelFor={workspaceLabelFor}
onOpen={(session) => openMobileSession(session.id)}
/>
)}
</div>
</div>
)
}
function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: string }) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`h-9 max-w-[180px] shrink-0 truncate rounded-[var(--radius-full)] border px-3.5 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
active
? 'border-[var(--color-text-primary)] bg-[var(--color-text-primary)] text-[var(--color-surface)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-secondary)]'
}`}
>
{children}
</button>
)
}
/**
* Projects hidden on the desktop stay hidden here. A phone usually starts
* with an empty cache, so the server's copy decides once it answers.
*/
function useHiddenProjectKeys(
preferencesRequest: Promise<DesktopUiPreferencesResponse> | null,
): ReadonlySet<string> {
const [hidden, setHidden] = useState<ReadonlySet<string>>(
() => new Set(readCachedSidebarProjectPreferences().hiddenProjects),
)
useEffect(() => {
if (!preferencesRequest) return
let cancelled = false
void preferencesRequest
.then((response) => {
if (cancelled || !response.exists) return
const preferences = normalizeSidebarProjectPreferences(response.preferences.sidebar)
setHidden(new Set(preferences.hiddenProjects))
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [preferencesRequest])
return hidden
}
@@ -0,0 +1,289 @@
import { useCallback, useId, useState, type FormEvent, type ReactNode } from 'react'
import { ChevronRight, PencilLine, Trash2 } from 'lucide-react'
import { useTranslation, type TranslationKey } from '../../i18n'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { Input } from '@/components/ui/Input'
import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet'
import { Spinner } from '@/components/ui/Spinner'
import { StatusDot } from '@/components/ui/Badge'
import { useLongPress } from '../../hooks/useLongPress'
import { formatRelativeTime } from '../../lib/formatRelativeTime'
import { releaseWorkspaceSession } from '../../lib/workspace/releaseSession'
import { useSessionStore } from '../../stores/sessionStore'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import type { SessionListItem } from '../../types/session'
import { SessionAttentionMark } from '../layout/SessionAttentionMark'
import type { MobileSessionGroup, MobileSessionGroupId } from './mobileSessionGroups'
const GROUP_LABEL_KEYS: Record<MobileSessionGroupId, TranslationKey> = {
attention: 'mobile.group.attention',
running: 'sidebar.taskGroup.running',
today: 'sidebar.taskGroup.today',
yesterday: 'sidebar.taskGroup.yesterday',
last7Days: 'sidebar.taskGroup.last7Days',
last30Days: 'sidebar.taskGroup.last30Days',
earlier: 'sidebar.taskGroup.earlier',
}
type Props = {
groups: MobileSessionGroup[]
runningIds: ReadonlySet<string>
attentionIds: ReadonlySet<string>
/** Highlighted row, for the tablet pane where the open session sits beside the list. */
selectedSessionId?: string | null
workspaceLabelFor: (session: SessionListItem) => string
onOpen: (session: SessionListItem) => void
}
/**
* The phone's session list. Tapping a row opens the session; pressing and
* holding it opens a sheet with rename and delete, which on the desktop live
* behind a right click that a phone cannot make.
*/
export function MobileSessionList({
groups,
runningIds,
attentionIds,
selectedSessionId = null,
workspaceLabelFor,
onOpen,
}: Props) {
const t = useTranslation()
const [actionSession, setActionSession] = useState<SessionListItem | null>(null)
const [renaming, setRenaming] = useState<SessionListItem | null>(null)
const [renameValue, setRenameValue] = useState('')
const [pendingDelete, setPendingDelete] = useState<SessionListItem | null>(null)
const renameSession = useSessionStore((state) => state.renameSession)
const deleteSession = useSessionStore((state) => state.deleteSession)
const addToast = useUIStore((state) => state.addToast)
const closeActions = useCallback(() => setActionSession(null), [])
const rowHintId = useId()
const startRename = () => {
if (!actionSession) return
setRenameValue(actionSession.title || '')
setRenaming(actionSession)
setActionSession(null)
}
const submitRename = async (event: FormEvent) => {
event.preventDefault()
if (!renaming) return
const title = renameValue.trim()
const session = renaming
setRenaming(null)
if (!title || title === session.title) return
try {
await renameSession(session.id, title)
} catch {
addToast({ type: 'error', message: t('mobile.session.renameFailed') })
}
}
const confirmDelete = async () => {
if (!pendingDelete) return
const sessionId = pendingDelete.id
setPendingDelete(null)
try {
await deleteSession(sessionId)
} catch {
addToast({ type: 'error', message: t('mobile.session.deleteFailed') })
return
}
useChatStore.getState().disconnectSession(sessionId)
releaseWorkspaceSession(sessionId)
useTabStore.getState().closeTab(sessionId)
}
return (
<>
<div data-testid="mobile-session-list" className="flex flex-col pb-3">
<p id={rowHintId} className="sr-only">{t('mobile.session.actionsHint')}</p>
{groups.map((group) => (
<section key={group.id} aria-labelledby={`mobile-session-group-${group.id}`}>
<h2
id={`mobile-session-group-${group.id}`}
className={`sticky top-0 z-[var(--z-raised)] flex items-center gap-1.5 bg-[var(--color-surface)] px-4 pb-1.5 pt-3 text-[12px] font-medium ${
group.id === 'attention'
? 'text-[var(--color-on-warning-container)]'
: 'text-[var(--color-text-tertiary)]'
}`}
>
{t(GROUP_LABEL_KEYS[group.id])}
<span className="tabular-nums">· {group.sessions.length}</span>
</h2>
<ul className={group.id === 'attention'
? 'mx-3 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-warning)] bg-[var(--color-warning-container)]'
: 'mx-3'}
>
{group.sessions.map((session) => (
<li key={session.id} className="border-b border-[var(--color-border)] last:border-b-0">
<MobileSessionRow
session={session}
running={runningIds.has(session.id)}
waiting={attentionIds.has(session.id)}
selected={session.id === selectedSessionId}
workspaceLabel={workspaceLabelFor(session)}
hintId={rowHintId}
onOpen={onOpen}
onLongPress={setActionSession}
/>
</li>
))}
</ul>
</section>
))}
</div>
<MobileBottomSheet
open={actionSession !== null}
onClose={closeActions}
title={<span className="block truncate">{actionSession?.title || t('session.untitled')}</span>}
ariaLabel={actionSession?.title || t('session.untitled')}
closeLabel={t('common.close')}
testId="mobile-session-actions"
>
<div className="flex flex-col p-2" role="menu">
<SheetAction icon={<PencilLine size={18} strokeWidth={1.75} aria-hidden="true" />} onClick={startRename}>
{t('common.rename')}
</SheetAction>
<SheetAction
danger
icon={<Trash2 size={18} strokeWidth={1.75} aria-hidden="true" />}
onClick={() => {
setPendingDelete(actionSession)
setActionSession(null)
}}
>
{t('common.delete')}
</SheetAction>
</div>
</MobileBottomSheet>
<MobileBottomSheet
open={renaming !== null}
onClose={() => setRenaming(null)}
title={t('mobile.session.renameTitle')}
closeLabel={t('common.close')}
testId="mobile-session-rename"
>
<form className="flex flex-col gap-3 p-4" onSubmit={submitRename}>
<Input
label={t('mobile.session.renameLabel')}
size="xl"
value={renameValue}
autoFocus
onChange={(event) => setRenameValue(event.target.value)}
/>
<div className="grid grid-cols-2 gap-2">
<Button type="button" variant="secondary" size="lg" block onClick={() => setRenaming(null)}>
{t('common.cancel')}
</Button>
<Button type="submit" variant="primary" size="lg" block disabled={!renameValue.trim()}>
{t('common.save')}
</Button>
</div>
</form>
</MobileBottomSheet>
<ConfirmDialog
open={pendingDelete !== null}
onClose={() => setPendingDelete(null)}
onConfirm={confirmDelete}
title={t('common.delete')}
body={pendingDelete ? t('sidebar.confirmDelete') : ''}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
/>
</>
)
}
function MobileSessionRow({
session,
running,
waiting,
selected,
workspaceLabel,
hintId,
onOpen,
onLongPress,
}: {
session: SessionListItem
running: boolean
waiting: boolean
selected: boolean
workspaceLabel: string
hintId: string
onOpen: (session: SessionListItem) => void
onLongPress: (session: SessionListItem) => void
}) {
const t = useTranslation()
const longPress = useLongPress({ onLongPress: () => onLongPress(session) })
const relativeTime = formatRelativeTime(session.modifiedAt, t)
const title = session.title || t('session.untitled')
return (
<button
type="button"
data-mobile-session-id={session.id}
aria-current={selected ? 'true' : undefined}
aria-describedby={hintId}
onClick={() => onOpen(session)}
{...longPress}
className={`flex min-h-[56px] w-full select-none items-center gap-3 px-1 py-2 text-left [-webkit-touch-callout:none] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)] ${
selected ? 'rounded-[var(--radius-md)] bg-[var(--color-surface-selected)]' : 'active:bg-[var(--color-surface-hover)]'
}`}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
{waiting ? (
<SessionAttentionMark label={t('mobile.session.waiting')} />
) : running ? (
<span role="img" aria-label={t('mobile.session.running')} className="inline-flex text-[var(--color-info)]">
<Spinner size={14} />
</span>
) : (
<StatusDot tone="neutral" size="sm" />
)}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-[14px] font-medium leading-5 text-[var(--color-text-primary)]">{title}</span>
<span className="truncate text-[12px] leading-4 text-[var(--color-text-tertiary)]">
{[workspaceLabel, relativeTime].filter(Boolean).join(' · ')}
</span>
</span>
<ChevronRight size={16} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
)
}
function SheetAction({
icon,
danger = false,
onClick,
children,
}: {
icon: ReactNode
danger?: boolean
onClick: () => void
children: ReactNode
}) {
return (
<button
type="button"
role="menuitem"
onClick={onClick}
className={`flex min-h-12 items-center gap-3 rounded-[var(--radius-md)] px-3 text-left text-[15px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] active:bg-[var(--color-surface-hover)] ${
danger ? 'text-[var(--color-error)]' : 'text-[var(--color-text-primary)]'
}`}
>
{icon}
{children}
</button>
)
}
@@ -0,0 +1,255 @@
import type { ReactNode } from 'react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { useTabStore, type Tab } from '../../stores/tabStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useChatStore } from '../../stores/chatStore'
import { useActivityPanelStore } from '../../stores/activityPanelStore'
import type { SessionListItem } from '../../types/session'
import { LONG_PRESS_DELAY_MS } from '../../hooks/useLongPress'
const mocks = vi.hoisted(() => ({
live: [] as Array<{ id: string; activityState: 'running' | 'waiting' }>,
}))
vi.mock('../../api/sessions', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api/sessions')>()
return {
...actual,
sessionsApi: {
...actual.sessionsApi,
getLiveStatus: vi.fn(async () => ({ sessions: mocks.live })),
},
}
})
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key,
}))
// The router mounts real chat pages; the shell's job is only to choose between
// home and a page and to frame the page, so a stub that shows which it chose
// is enough here.
vi.mock('../layout/ContentRouter', () => ({
ContentRouter: ({ homePage }: { homePage?: ReactNode }) => {
const activeTabId = useTabStore((state) => state.activeTabId)
if (!activeTabId) return <>{homePage ?? <div>desktop new-session page</div>}</>
return <div data-testid="routed-page">page for {activeTabId}</div>
},
}))
vi.mock('../../pages/EmptySession', () => ({
EmptySession: ({ mobileHome }: { mobileHome?: ReactNode }) => (
<div>
{mobileHome}
<div>new task composer</div>
</div>
),
}))
vi.mock('../layout/UpdateChecker', () => ({ UpdateChecker: () => null }))
vi.mock('../layout/Toast', () => ({ ToastContainer: () => null }))
import { MobileShell } from './MobileShell'
class TestPointerEvent extends MouseEvent {
pointerType: string
isPrimary: boolean
constructor(type: string, init: MouseEventInit & { pointerType?: string; isPrimary?: boolean } = {}) {
super(type, init)
this.pointerType = init.pointerType ?? 'touch'
this.isPrimary = init.isPrimary ?? true
}
}
function session(id: string, title: string, minutesAgo = 5): SessionListItem {
const modifiedAt = new Date(Date.now() - minutesAgo * 60_000).toISOString()
return {
id,
title,
createdAt: modifiedAt,
modifiedAt,
messageCount: 3,
projectPath: '/work/cc-haha',
projectRoot: '/work/cc-haha',
workDir: '/work/cc-haha',
workDirExists: true,
}
}
const connectToSession = vi.fn()
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 } = {}) {
useTabStore.setState({ tabs, activeTabId })
useSessionStore.setState({
sessions: [session('s-login', 'Fix login i18n'), session('s-release', 'Draft release notes', 90)],
isLoading: false,
error: null,
fetchSessions: vi.fn(async () => undefined),
renameSession,
deleteSession,
})
useChatStore.setState({ sessions: {}, connectToSession, disconnectSession })
}
function renderShell(layout: 'phone' | 'tablet' = 'phone') {
return render(<MobileShell layout={layout} preferencesRequest={null} />)
}
describe('MobileShell', () => {
beforeAll(() => {
if (!('PointerEvent' in window)) {
Object.defineProperty(window, 'PointerEvent', { configurable: true, value: TestPointerEvent })
}
})
beforeEach(() => {
vi.clearAllMocks()
mocks.live = []
window.history.replaceState(null, '', '/')
seed()
})
afterEach(() => {
vi.useRealTimers()
})
it('opens on the session list, with sessions waiting elsewhere listed first', async () => {
mocks.live = [{ id: 's-release', activityState: 'waiting' }]
renderShell()
const list = await screen.findByTestId('mobile-session-list')
await waitFor(() => {
expect(within(list).getAllByRole('heading')[0]).toHaveTextContent('mobile.group.attention')
})
const sections = within(list).getAllByRole('region')
expect(within(sections[0]!).getByText('Draft release notes')).toBeInTheDocument()
expect(screen.getByText('new task composer')).toBeInTheDocument()
expect(screen.queryByTestId('mobile-top-bar')).not.toBeInTheDocument()
})
it('goes into a session and comes back with the Back button', async () => {
renderShell()
fireEvent.click(await screen.findByText('Fix login i18n'))
expect(connectToSession).toHaveBeenCalledWith('s-login')
expect(screen.getByTestId('routed-page')).toHaveTextContent('page for s-login')
expect(screen.getByTestId('mobile-top-bar')).toHaveTextContent('Fix login i18n')
expect(window.history.state).toEqual({ ccHahaMobileGuard: true })
fireEvent.click(screen.getByTestId('mobile-back'))
await waitFor(() => expect(useTabStore.getState().activeTabId).toBeNull())
expect(screen.getByTestId('mobile-session-list')).toBeInTheDocument()
})
it('lets the system back gesture leave a detail page for its session', async () => {
const tabs: Tab[] = [
{ sessionId: 's-login', title: 'Fix login i18n', type: 'session', status: 'idle' },
{
sessionId: '__subagent__s-login:tool-1',
title: 'Explore the H5 shell',
type: 'subagent',
status: 'idle',
sourceSessionId: 's-login',
subagentToolUseId: 'tool-1',
},
]
seed({ tabs, activeTabId: '__subagent__s-login:tool-1' })
renderShell()
expect(screen.getByTestId('mobile-top-bar')).toHaveTextContent('Explore the H5 shell')
act(() => { window.history.back() })
await waitFor(() => expect(useTabStore.getState().activeTabId).toBe('s-login'))
expect(useTabStore.getState().tabs.some((tab) => tab.type === 'subagent')).toBe(false)
// Still above home, so the next back must be caught too.
await waitFor(() => expect(window.history.state).toEqual({ ccHahaMobileGuard: true }))
})
it('sends a desktop-only tab home instead of showing a blank frame', async () => {
seed({
tabs: [{ sessionId: '__terminal__1', title: 'Terminal', type: 'terminal', status: 'idle' }],
activeTabId: '__terminal__1',
})
renderShell()
await waitFor(() => expect(useTabStore.getState().activeTabId).toBeNull())
expect(useTabStore.getState().tabs).toHaveLength(1)
})
it('keeps the list beside the session on a tablet, without a Back for 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')
const row = await within(pane).findByText('Fix login i18n')
expect(row.closest('button')).toHaveAttribute('aria-current', 'true')
expect(screen.getByTestId('mobile-top-bar')).toHaveTextContent('Fix login i18n')
expect(screen.queryByTestId('mobile-back')).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' }],
activeTabId: 's-login',
})
renderShell()
expect(screen.queryByTestId('mobile-activity-pill')).not.toBeInTheDocument()
act(() => useActivityPanelStore.getState().setMobileSummary('s-login', { visible: true, count: 3 }))
const pill = screen.getByTestId('mobile-activity-pill')
expect(pill).toHaveTextContent('mobile.activity.inProgress:{"count":3}')
fireEvent.click(pill)
expect(useActivityPanelStore.getState().isOpen('s-login')).toBe(true)
act(() => useActivityPanelStore.getState().close())
})
it('renames a session from the press-and-hold sheet', async () => {
renderShell()
const row = (await screen.findByText('Fix login i18n')).closest('button')!
vi.useFakeTimers()
fireEvent.pointerDown(row, { pointerType: 'touch', isPrimary: true, clientX: 5, clientY: 5 })
act(() => { vi.advanceTimersByTime(LONG_PRESS_DELAY_MS) })
fireEvent.pointerUp(row)
fireEvent.click(row)
vi.useRealTimers()
// The press opened the sheet, not the session underneath it.
expect(useTabStore.getState().activeTabId).toBeNull()
const sheet = screen.getByTestId('mobile-session-actions')
fireEvent.click(within(sheet).getByRole('menuitem', { name: 'common.rename' }))
const field = within(screen.getByTestId('mobile-session-rename')).getByRole('textbox')
fireEvent.change(field, { target: { value: 'Login copy in five languages' } })
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() => expect(renameSession).toHaveBeenCalledWith('s-login', 'Login copy in five languages'))
})
it('deletes a session after confirming, and lets go of its socket and tab', async () => {
seed({ tabs: [{ sessionId: 's-login', title: 'Fix login i18n', type: 'session', status: 'idle' }] })
renderShell()
const row = (await screen.findByText('Fix login i18n')).closest('button')!
fireEvent.contextMenu(row)
fireEvent.click(within(screen.getByTestId('mobile-session-actions')).getByRole('menuitem', { name: 'common.delete' }))
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(deleteSession).toHaveBeenCalledWith('s-login'))
await waitFor(() => expect(disconnectSession).toHaveBeenCalledWith('s-login'))
expect(useTabStore.getState().tabs).toHaveLength(0)
})
})
@@ -0,0 +1,164 @@
import { useEffect, type ReactNode } from 'react'
import { useTranslation } from '../../i18n'
import { StatusDot } from '@/components/ui/Badge'
import type { DesktopUiPreferencesResponse } from '../../api/desktopUiPreferences'
import { sessionNeedsAttention } from '../../lib/sessionAttention'
import { formatRelativeTime } from '../../lib/formatRelativeTime'
import { resolveProjectDisplayName } from '../../stores/projectDisplayNameStore'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { EmptySession } from '../../pages/EmptySession'
import { ContentRouter } from '../layout/ContentRouter'
import { getSessionWorkspaceLabel } from '../layout/sidebarTaskGroups'
import { ToastContainer } from '../layout/Toast'
import { UpdateChecker } from '../layout/UpdateChecker'
import { WorkspaceHeaderProvider } from '../layout/WorkspaceHeaderContext'
import { MobileSessionBrowser } from './MobileSessionBrowser'
import { MobileTopBar } from './MobileTopBar'
import { MobileActivityPill } from './MobileActivityPill'
import type { MobileShellLayout } from './mobileShellLayout'
import {
goMobileHome,
isMobileRoutableTab,
mobileRouteDepth,
resolveMobileRoute,
useMobileHistoryGuard,
type MobileRoute,
} from './mobileNavigation'
type Props = {
layout: Exclude<MobileShellLayout, 'desktop'>
preferencesRequest: Promise<DesktopUiPreferencesResponse> | null
}
/**
* 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.
*
* Tablet: the same session list stays on the left and the page sits beside it.
*/
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)
// 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
// blank frame. The tab itself is kept for when the desktop opens it.
useEffect(() => {
if (activeTab && !isMobileRoutableTab(activeTab)) goMobileHome()
}, [activeTab])
const showBack = route.kind !== 'home' && (layout === 'phone' || route.kind !== 'session')
return (
<div
data-testid="mobile-shell"
data-layout={layout}
className="app-shell app-shell-viewport app-shell--mobile flex overflow-hidden bg-[var(--color-surface)]"
>
{layout === 'tablet' ? (
<aside
data-testid="mobile-tablet-pane"
className="flex w-[340px] shrink-0 flex-col border-r border-[var(--color-border)]"
>
<MobileSessionBrowser
variant="pane"
selectedSessionId={route.kind === 'session' ? route.tabId : null}
preferencesRequest={preferencesRequest}
/>
</aside>
) : null}
<main
id="content-area"
className="app-shell-main--mobile flex min-w-0 flex-1 flex-col overflow-hidden"
>
{route.kind !== 'home' ? (
<MobileRouteBar route={route} onBack={showBack ? goBack : undefined} />
) : null}
<WorkspaceHeaderProvider>
<ContentRouter
homePage={layout === 'phone'
? <EmptySession mobileHome={<MobileSessionBrowser variant="home" preferencesRequest={preferencesRequest} />} />
: undefined}
/>
</WorkspaceHeaderProvider>
</main>
<ToastContainer />
<UpdateChecker />
</div>
)
}
function MobileRouteBar({ route, onBack }: { route: Exclude<MobileRoute, { kind: 'home' }>; onBack?: () => void }) {
const t = useTranslation()
const tab = useTabStore((state) => state.tabs.find((candidate) => candidate.sessionId === route.tabId))
if (route.kind === 'settings') {
return <MobileTopBar title={t('sidebar.settings')} onBack={onBack} backLabel={t('mobile.nav.backToSessions')} />
}
if (route.kind === 'detail') {
return <MobileTopBar title={tab?.title || t('session.untitled')} onBack={onBack} activeSessionId={route.parentSessionId} />
}
return <MobileSessionBar sessionId={route.tabId} fallbackTitle={tab?.title} onBack={onBack} />
}
function MobileSessionBar({
sessionId,
fallbackTitle,
onBack,
}: {
sessionId: string
fallbackTitle?: string
onBack?: () => void
}) {
const t = useTranslation()
const session = useSessionStore((state) => state.sessions.find((candidate) => candidate.id === sessionId))
const waiting = useChatStore((state) => sessionNeedsAttention(state.sessions[sessionId]))
const running = useChatStore((state) => {
const chat = state.sessions[sessionId]
return Boolean(chat && chat.chatState !== 'idle')
})
let subtitle: ReactNode = null
if (waiting) {
subtitle = (
<span className="flex items-center gap-1.5 font-medium text-[var(--color-on-warning-container)]">
<StatusDot tone="warning" />
{t('mobile.session.waiting')}
</span>
)
} else if (running) {
subtitle = (
<span className="flex items-center gap-1.5 text-[var(--color-text-secondary)]">
<StatusDot tone="info" pulse />
{t('mobile.session.running')}
</span>
)
} else if (session) {
subtitle = (
<span className="truncate">
{[getSessionWorkspaceLabel(session, resolveProjectDisplayName), formatRelativeTime(session.modifiedAt, t)]
.filter(Boolean)
.join(' · ')}
</span>
)
}
return (
<MobileTopBar
title={session?.title || fallbackTitle || t('session.untitled')}
subtitle={subtitle}
onBack={onBack}
backLabel={t('mobile.nav.backToSessions')}
activeSessionId={sessionId}
trailing={<MobileActivityPill sessionId={sessionId} />}
/>
)
}
@@ -0,0 +1,57 @@
import type { ReactNode } from 'react'
import { ChevronLeft } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { IconButton } from '@/components/ui/IconButton'
import { MobileAttentionDot } from '../layout/MobileAttentionDot'
type Props = {
title: string
subtitle?: ReactNode
/** Absent on a page with nothing above it (the tablet's session pane). */
onBack?: () => void
backLabel?: string
/** The session on screen, so the dot on Back only counts the others. */
activeSessionId?: string | null
trailing?: ReactNode
}
/**
* The bar above every page that is not home: Back on the left, the page's
* name, and room on the right for page actions. Back carries the dot that
* says another session is waiting, because the list it returns to is where
* that session is.
*/
export function MobileTopBar({ title, subtitle, onBack, backLabel, activeSessionId = null, trailing }: Props) {
const t = useTranslation()
return (
<div
data-testid="mobile-top-bar"
className="flex min-h-14 shrink-0 items-center gap-1 border-b border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-1 pr-2"
>
{onBack ? (
<span className="relative inline-flex shrink-0">
<IconButton
data-testid="mobile-back"
size="2xl"
tone="secondary"
icon={<ChevronLeft size={22} strokeWidth={1.75} aria-hidden="true" />}
label={backLabel ?? t('mobile.nav.back')}
onClick={onBack}
/>
<MobileAttentionDot activeSessionId={activeSessionId} />
</span>
) : (
<span className="w-3 shrink-0" aria-hidden="true" />
)}
<div className="min-w-0 flex-1">
<h1 className="truncate text-[15px] font-semibold leading-tight text-[var(--color-text-primary)]">{title}</h1>
{subtitle ? (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap text-[12px] text-[var(--color-text-tertiary)]">
{subtitle}
</div>
) : null}
</div>
{trailing ? <div className="flex shrink-0 items-center gap-1">{trailing}</div> : null}
</div>
)
}
@@ -0,0 +1,146 @@
import { useEffect, useRef } from 'react'
import { useTabStore, type Tab } from '../../stores/tabStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useChatStore } from '../../stores/chatStore'
/**
* Where the phone shell is. Home is the session list; a session, Settings and
* the detail pages (a subagent run, a team member) stack on top of it. Every
* other tab type is a desktop surface with no phone layout and is sent home.
*/
export type MobileRoute =
| { kind: 'home' }
| { kind: 'session'; tabId: string }
| { kind: 'settings'; tabId: string }
| { kind: 'detail'; tabId: string; parentSessionId: string | null }
const HOME: MobileRoute = { kind: 'home' }
export function resolveMobileRoute(tabs: readonly Tab[], activeTabId: string | null): MobileRoute {
if (!activeTabId) return HOME
const tab = tabs.find((candidate) => candidate.sessionId === activeTabId)
if (!tab) return HOME
switch (tab.type) {
case 'session':
return { kind: 'session', tabId: tab.sessionId }
case 'settings':
return { kind: 'settings', tabId: tab.sessionId }
case 'subagent':
return { kind: 'detail', tabId: tab.sessionId, parentSessionId: tab.sourceSessionId ?? null }
case 'team-member':
return { kind: 'detail', tabId: tab.sessionId, parentSessionId: tab.teamLeadSessionId ?? null }
default:
return HOME
}
}
/** How many pages sit above home; the browser history mirrors this. */
export function mobileRouteDepth(route: MobileRoute): number {
if (route.kind === 'home') return 0
if (route.kind === 'detail') return 2
return 1
}
/** Whether a tab can be shown by the phone shell at all. */
export function isMobileRoutableTab(tab: Tab | undefined): boolean {
return tab ? resolveMobileRoute([tab], tab.sessionId).kind !== 'home' : false
}
export function goMobileHome(): void {
useTabStore.setState({ activeTabId: null })
useTabStore.getState().saveTabs()
}
export function openMobileSession(sessionId: string): void {
const tabs = useTabStore.getState().tabs
if (tabs.some((tab) => tab.sessionId === sessionId && tab.type === 'session')) {
useTabStore.getState().setActiveTab(sessionId)
} else {
const session = useSessionStore.getState().sessions.find((candidate) => candidate.id === sessionId)
if (session) {
useSessionStore.getState().openHistoricalSession(session)
} else {
useTabStore.getState().openTab(sessionId, '')
}
}
useChatStore.getState().connectToSession(sessionId)
}
/**
* One level up. A detail page is closed rather than kept: on a phone it is a
* look at something inside its session, not a tab of its own to come back to,
* and leaving it open would bring it back the next time the session is shown.
*/
export function navigateMobileUp(): void {
const { tabs, activeTabId } = useTabStore.getState()
const route = resolveMobileRoute(tabs, activeTabId)
if (route.kind === 'home') return
if (route.kind === 'detail') {
useTabStore.getState().closeTab(route.tabId)
if (route.parentSessionId) {
openMobileSession(route.parentSessionId)
return
}
}
goMobileHome()
}
type HistoryGuardState = { ccHahaMobileGuard: true }
function isGuardState(state: unknown): state is HistoryGuardState {
return typeof state === 'object' && state !== null && (state as HistoryGuardState).ccHahaMobileGuard === true
}
/**
* Lets the system back gesture (iOS edge swipe, Android back, WeChat's back
* button) go up one page instead of leaving the app.
*
* While any page sits above home, one extra history entry is kept on top of
* the stack. Going back pops it; the popstate handler then moves the shell up
* a level and, if that level is still above home, pushes the entry again. An
* in-app back button calls `history.back()` through the returned function so
* 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.
*/
export function useMobileHistoryGuard(depth: number, enabled: boolean): () => void {
const guardedRef = useRef(false)
const ignoreNextPopRef = useRef(false)
useEffect(() => {
if (!enabled) return
if (depth > 0 && !guardedRef.current) {
window.history.pushState({ ccHahaMobileGuard: true } satisfies HistoryGuardState, '')
guardedRef.current = true
} else if (depth === 0 && guardedRef.current) {
guardedRef.current = false
if (isGuardState(window.history.state)) {
ignoreNextPopRef.current = true
window.history.back()
}
}
}, [depth, enabled])
useEffect(() => {
if (!enabled) return
const handlePopState = () => {
if (ignoreNextPopRef.current) {
ignoreNextPopRef.current = false
return
}
if (!guardedRef.current) return
guardedRef.current = false
navigateMobileUp()
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [enabled])
return () => {
if (enabled && guardedRef.current && isGuardState(window.history.state)) {
window.history.back()
return
}
navigateMobileUp()
}
}
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import type { SessionListItem } from '../../types/session'
import {
buildMobileSessionGroups,
filterMobileSessions,
listMobileProjectFilters,
} from './mobileSessionGroups'
const NOW = new Date('2026-10-06T12:00:00').getTime()
function session(id: string, modifiedAt: string, projectRoot = '/work/cc-haha', title = id): SessionListItem {
return {
id,
title,
createdAt: modifiedAt,
modifiedAt,
messageCount: 1,
projectPath: projectRoot,
projectRoot,
workDir: projectRoot,
workDirExists: true,
}
}
describe('buildMobileSessionGroups', () => {
it('puts waiting sessions first, then working ones, then the day buckets', () => {
const groups = buildMobileSessionGroups(
[
session('old', '2026-09-01T10:00:00'),
session('today', '2026-10-06T09:00:00'),
session('working', '2026-10-05T10:00:00'),
session('waiting', '2026-09-20T10:00:00'),
],
new Set(['working', 'waiting']),
new Set(['waiting']),
NOW,
)
expect(groups.map((group) => [group.id, group.sessions.map((item) => item.id)])).toEqual([
['attention', ['waiting']],
['running', ['working']],
['today', ['today']],
['earlier', ['old']],
])
})
it('lists a session once even when it is both running and waiting', () => {
const groups = buildMobileSessionGroups(
[session('both', '2026-10-06T09:00:00')],
new Set(['both']),
new Set(['both']),
NOW,
)
expect(groups.flatMap((group) => group.sessions.map((item) => item.id))).toEqual(['both'])
expect(groups[0]?.id).toBe('attention')
})
it('omits the waiting section when nothing waits', () => {
const groups = buildMobileSessionGroups([session('a', '2026-10-06T09:00:00')], new Set(), new Set(), NOW)
expect(groups.map((group) => group.id)).toEqual(['today'])
})
})
describe('mobile session filters', () => {
const sessions = [
session('a', '2026-10-01T10:00:00', '/work/cc-haha', 'Fix login i18n'),
session('b', '2026-10-06T10:00:00', '/work/crawler', 'Export comments'),
session('c', '2026-10-03T10:00:00', '/work/cc-haha', 'Release notes'),
]
it('orders project chips by their most recent session', () => {
const filters = listMobileProjectFilters(sessions, (item) => item.projectRoot ?? '')
expect(filters.map((filter) => filter.key)).toEqual(['/work/crawler', '/work/cc-haha'])
})
it('narrows by project and by a case-insensitive title match together', () => {
expect(filterMobileSessions(sessions, { projectKey: '/work/cc-haha', query: '' }).map((item) => item.id))
.toEqual(['a', 'c'])
expect(filterMobileSessions(sessions, { projectKey: '/work/cc-haha', query: 'LOGIN' }).map((item) => item.id))
.toEqual(['a'])
expect(filterMobileSessions(sessions, { projectKey: null, query: ' ' }).map((item) => item.id))
.toEqual(['a', 'b', 'c'])
})
})
@@ -0,0 +1,72 @@
import type { SessionListItem } from '../../types/session'
import {
buildSidebarTaskGroups,
getSessionProjectKey,
type SidebarTaskGroupId,
} from '../layout/sidebarTaskGroups'
/**
* The phone list's sections, most urgent first: sessions waiting on the
* person, then sessions working, then the same day buckets the desktop task
* view uses. A session appears in exactly one section.
*/
export type MobileSessionGroupId = 'attention' | SidebarTaskGroupId
export type MobileSessionGroup = {
id: MobileSessionGroupId
sessions: SessionListItem[]
}
export function buildMobileSessionGroups(
sessions: readonly SessionListItem[],
runningIds: ReadonlySet<string>,
attentionIds: ReadonlySet<string>,
now: number,
): MobileSessionGroup[] {
const attention: SessionListItem[] = []
const rest: SessionListItem[] = []
for (const session of sessions) {
if (attentionIds.has(session.id)) attention.push(session)
else rest.push(session)
}
const groups: MobileSessionGroup[] = []
if (attention.length > 0) {
groups.push({
id: 'attention',
sessions: [...attention].sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt)),
})
}
return [...groups, ...buildSidebarTaskGroups(rest, runningIds, now)]
}
export type MobileProjectFilter = {
key: string
label: string
}
/** Projects in the order of their most recent session, for the filter chips. */
export function listMobileProjectFilters(
sessions: readonly SessionListItem[],
labelFor: (session: SessionListItem) => string,
): MobileProjectFilter[] {
const seen = new Map<string, MobileProjectFilter>()
const newestFirst = [...sessions].sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt))
for (const session of newestFirst) {
const key = getSessionProjectKey(session)
if (!seen.has(key)) seen.set(key, { key, label: labelFor(session) })
}
return [...seen.values()]
}
export function filterMobileSessions(
sessions: readonly SessionListItem[],
{ projectKey, query }: { projectKey: string | null; query: string },
): SessionListItem[] {
const needle = query.trim().toLocaleLowerCase()
return sessions.filter((session) => {
if (projectKey && getSessionProjectKey(session) !== projectKey) return false
if (needle && !(session.title || '').toLocaleLowerCase().includes(needle)) return false
return true
})
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import type { Tab } from '../../stores/tabStore'
import { deriveMobileSessionStatus } from './mobileSessionStatus'
type ChatSource = Parameters<typeof deriveMobileSessionStatus>[1][string]
function chat(overrides: Partial<NonNullable<ChatSource>> = {}): NonNullable<ChatSource> {
return {
chatState: 'idle',
connectionState: 'connected',
pendingPermission: null,
pendingPermissions: undefined,
backgroundAgentTasks: undefined,
...overrides,
} as NonNullable<ChatSource>
}
const pending = {
requestId: 'permission-1',
toolName: 'Bash',
input: { command: 'bun run build' },
} as never
describe('deriveMobileSessionStatus', () => {
it('reads sessions nobody here connected to from the server answer', () => {
const status = deriveMobileSessionStatus(
[],
{},
new Map([['remote-waiting', 'waiting'], ['remote-running', 'running']]),
)
expect([...status.attentionIds]).toEqual(['remote-waiting'])
expect([...status.runningIds]).toEqual(['remote-running'])
})
it('trusts a live socket over a poll that has not caught up yet', () => {
// Answered on the phone a moment ago: the store has cleared the request,
// the last poll still says waiting. The row must not keep asking.
const status = deriveMobileSessionStatus(
[],
{ answered: chat({ chatState: 'idle' }) },
new Map([['answered', 'waiting']]),
)
expect(status.attentionIds.has('answered')).toBe(false)
expect(status.runningIds.has('answered')).toBe(false)
})
it('still uses the poll for a session whose socket has dropped', () => {
const status = deriveMobileSessionStatus(
[],
{ dropped: chat({ connectionState: 'reconnecting' }) },
new Map([['dropped', 'waiting']]),
)
expect(status.attentionIds.has('dropped')).toBe(true)
})
it('counts a connected session parked on a card as waiting, not working', () => {
const status = deriveMobileSessionStatus(
[{ sessionId: 'card', title: 'Card', type: 'session', status: 'running' } as Tab],
{ card: chat({ chatState: 'tool_executing', pendingPermission: pending }) },
new Map(),
)
expect(status.attentionIds.has('card')).toBe(true)
expect(status.runningIds.has('card')).toBe(false)
})
it('marks a tab the desktop reported running as working', () => {
const status = deriveMobileSessionStatus(
[{ sessionId: 'tab', title: 'Tab', type: 'session', status: 'running' } as Tab],
{},
new Map(),
)
expect(status.runningIds.has('tab')).toBe(true)
})
})
@@ -0,0 +1,71 @@
import { useMemo } from 'react'
import type { Tab } from '../../stores/tabStore'
import type { PerSessionState } from '../../stores/chatStore'
import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks'
import { sessionNeedsAttention } from '../../lib/sessionAttention'
import type { LiveActivityMap } from './useLiveSessionActivity'
export type MobileSessionStatus = {
runningIds: ReadonlySet<string>
attentionIds: ReadonlySet<string>
}
type ChatSource = Pick<
PerSessionState,
'chatState' | 'connectionState' | 'pendingPermission' | 'pendingPermissions' | 'backgroundAgentTasks'
>
/**
* Running and waiting sessions for the phone list.
*
* A session this page holds a live socket to is read from the chat store, the
* same records the cards render from (`sessionNeedsAttention`), so a request
* answered here clears at once instead of on the next poll. Every other
* session is read from the server's live answer, which is the only thing that
* knows about sessions started elsewhere.
*/
export function deriveMobileSessionStatus(
tabs: readonly Tab[],
chatSessions: Readonly<Record<string, ChatSource | undefined>>,
live: LiveActivityMap,
): MobileSessionStatus {
const runningIds = new Set<string>()
const attentionIds = new Set<string>()
const authoritative = new Set<string>()
for (const [sessionId, session] of Object.entries(chatSessions)) {
if (!session) continue
if (session.connectionState === 'connected') authoritative.add(sessionId)
if (sessionNeedsAttention(session)) {
attentionIds.add(sessionId)
} else if (session.chatState !== 'idle' || hasRunningBackgroundTasks(session.backgroundAgentTasks)) {
runningIds.add(sessionId)
}
}
for (const tab of tabs) {
if (tab.type === 'session' && tab.status === 'running' && !attentionIds.has(tab.sessionId)) {
runningIds.add(tab.sessionId)
}
}
for (const [sessionId, state] of live) {
if (authoritative.has(sessionId)) continue
if (state === 'waiting') {
attentionIds.add(sessionId)
runningIds.delete(sessionId)
} else if (!attentionIds.has(sessionId)) {
runningIds.add(sessionId)
}
}
return { runningIds, attentionIds }
}
export function useMobileSessionStatus(
tabs: readonly Tab[],
chatSessions: Readonly<Record<string, ChatSource | undefined>>,
live: LiveActivityMap,
): MobileSessionStatus {
return useMemo(() => deriveMobileSessionStatus(tabs, chatSessions, live), [chatSessions, live, tabs])
}
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react'
import { isTouchH5Document } from '../../lib/touchH5'
/**
* Which shell the app draws.
*
* - `desktop`: Electron, or a desktop browser at least a tablet wide.
* - `phone`: anything narrower than a tablet outside Electron. One page at a
* time, a stack you go back through.
* - `tablet`: a touch browser at least a tablet wide. The session list stays on
* the left and the page sits beside it, with the phone's touch controls —
* a touch screen gets no hover-only buttons or drag handles at any width.
*/
export type MobileShellLayout = 'desktop' | 'phone' | 'tablet'
export const TABLET_MIN_WIDTH_PX = 768
const PHONE_QUERY = `(max-width: ${TABLET_MIN_WIDTH_PX - 1}px)`
export function resolveMobileShellLayout({
desktopRuntime,
touch,
narrow,
}: {
desktopRuntime: boolean
touch: boolean
narrow: boolean
}): MobileShellLayout {
if (desktopRuntime) return 'desktop'
if (narrow) return 'phone'
return touch ? 'tablet' : 'desktop'
}
function matchesPhoneWidth(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
return window.matchMedia(PHONE_QUERY).matches
}
export function useMobileShellLayout(desktopRuntime: boolean): MobileShellLayout {
const [narrow, setNarrow] = useState(matchesPhoneWidth)
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
const query = window.matchMedia(PHONE_QUERY)
const update = () => setNarrow(query.matches)
update()
query.addEventListener?.('change', update)
return () => query.removeEventListener?.('change', update)
}, [])
return resolveMobileShellLayout({ desktopRuntime, touch: isTouchH5Document(), narrow })
}
@@ -0,0 +1,65 @@
import { act, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const getLiveStatus = vi.hoisted(() => vi.fn())
vi.mock('../../api/sessions', () => ({
sessionsApi: { getLiveStatus },
}))
import { LIVE_ACTIVITY_POLL_MS, useLiveSessionActivity } from './useLiveSessionActivity'
function Probe({ enabled = true }: { enabled?: boolean }) {
const activity = useLiveSessionActivity(enabled)
return <div data-testid="activity">{[...activity].map(([id, state]) => `${id}=${state}`).join(',')}</div>
}
async function flush() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
describe('useLiveSessionActivity', () => {
beforeEach(() => {
vi.useFakeTimers()
getLiveStatus.mockReset()
})
afterEach(() => vi.useRealTimers())
it('polls the server and follows its answer', async () => {
getLiveStatus
.mockResolvedValueOnce({ sessions: [{ id: 'a', activityState: 'running' }] })
.mockResolvedValueOnce({ sessions: [{ id: 'a', activityState: 'waiting' }] })
render(<Probe />)
await flush()
expect(screen.getByTestId('activity')).toHaveTextContent('a=running')
await act(async () => { vi.advanceTimersByTime(LIVE_ACTIVITY_POLL_MS) })
await flush()
expect(screen.getByTestId('activity')).toHaveTextContent('a=waiting')
})
it('keeps the last answer when one poll fails', async () => {
getLiveStatus
.mockResolvedValueOnce({ sessions: [{ id: 'a', activityState: 'waiting' }] })
.mockRejectedValueOnce(new Error('network'))
render(<Probe />)
await flush()
await act(async () => { vi.advanceTimersByTime(LIVE_ACTIVITY_POLL_MS) })
await flush()
expect(getLiveStatus).toHaveBeenCalledTimes(2)
expect(screen.getByTestId('activity')).toHaveTextContent('a=waiting')
})
it('does not poll while disabled', async () => {
render(<Probe enabled={false} />)
await act(async () => { vi.advanceTimersByTime(LIVE_ACTIVITY_POLL_MS * 3) })
expect(getLiveStatus).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react'
import { sessionsApi, type LiveSessionActivity } from '../../api/sessions'
import { isDocumentVisible } from '../../hooks/useSessionListAutoRefresh'
export const LIVE_ACTIVITY_POLL_MS = 5_000
export type LiveActivityMap = ReadonlyMap<string, LiveSessionActivity['activityState']>
const EMPTY: LiveActivityMap = new Map()
/**
* Which sessions the server says are working or waiting, for every session —
* not only the ones this page opened a socket to. The chat store only knows
* about connected sessions, so a session started on the desktop and parked on
* an approval would otherwise sit in "earlier" on the phone.
*
* Polls while the page is visible. A failed poll keeps the last answer: one
* dropped request on a phone network should not make every badge blink off.
*/
export function useLiveSessionActivity(enabled = true): LiveActivityMap {
const [activity, setActivity] = useState<LiveActivityMap>(EMPTY)
useEffect(() => {
if (!enabled) return
let cancelled = false
let controller: AbortController | null = null
const poll = () => {
if (!isDocumentVisible()) return
controller?.abort()
const current = new AbortController()
controller = current
void sessionsApi.getLiveStatus(current.signal)
.then(({ sessions }) => {
if (cancelled || current.signal.aborted) return
setActivity(new Map(sessions.map((entry) => [entry.id, entry.activityState])))
})
.catch(() => undefined)
}
poll()
const timer = window.setInterval(poll, LIVE_ACTIVITY_POLL_MS)
document.addEventListener('visibilitychange', poll)
return () => {
cancelled = true
controller?.abort()
window.clearInterval(timer)
document.removeEventListener('visibilitychange', poll)
}
}, [enabled])
return activity
}
@@ -17,6 +17,12 @@ type Props = {
panelClassName?: string
panelRef?: Ref<HTMLDivElement>
testId?: string
/**
* Nearly the full screen, for content read in full before acting on it (a
* plan to approve, a set of questions). The default stops at 78% so the page
* it was opened from stays in view above it.
*/
tall?: boolean
}
export function MobileBottomSheet({
@@ -34,6 +40,7 @@ export function MobileBottomSheet({
panelClassName = '',
panelRef,
testId,
tall = false,
}: Props) {
useEffect(() => {
if (!open) return
@@ -55,7 +62,7 @@ export function MobileBottomSheet({
aria-modal={role === 'dialog' ? true : undefined}
aria-label={ariaLabel ?? (typeof title === 'string' ? title : undefined)}
data-testid={testId}
className={`absolute inset-x-0 bottom-0 flex max-h-[min(78dvh,640px)] min-h-0 flex-col overflow-hidden rounded-t-[var(--radius-xl)] border-x-0 border-y border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)] ${panelClassName}`}
className={`absolute inset-x-0 bottom-0 flex ${tall ? 'max-h-[92dvh]' : 'max-h-[min(78dvh,640px)]'} min-h-0 flex-col overflow-hidden rounded-t-[var(--radius-xl)] border-x-0 border-y border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)] ${panelClassName}`}
onClick={(event) => event.stopPropagation()}
>
<div className="shrink-0 border-b border-[var(--color-border)] px-4 py-3">
+127
View File
@@ -0,0 +1,127 @@
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { LONG_PRESS_DELAY_MS, useLongPress } from './useLongPress'
// jsdom has no PointerEvent, so fireEvent falls back to a bare Event and drops
// the coordinates the move tolerance reads. A MouseEvent subclass carries them.
class TestPointerEvent extends MouseEvent {
pointerType: string
isPrimary: boolean
constructor(type: string, init: MouseEventInit & { pointerType?: string; isPrimary?: boolean } = {}) {
super(type, init)
this.pointerType = init.pointerType ?? 'touch'
this.isPrimary = init.isPrimary ?? true
}
}
beforeAll(() => {
if (!('PointerEvent' in window)) {
Object.defineProperty(window, 'PointerEvent', { configurable: true, value: TestPointerEvent })
}
})
function Row({ onLongPress, onClick }: { onLongPress: () => void; onClick: () => void }) {
const handlers = useLongPress({ onLongPress })
return <button type="button" onClick={onClick} {...handlers}>row</button>
}
function touchDown(element: HTMLElement, x = 10, y = 10) {
fireEvent.pointerDown(element, { pointerType: 'touch', isPrimary: true, clientX: x, clientY: y })
}
describe('useLongPress', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('fires after holding a touch and swallows the click that ends the press', () => {
const onLongPress = vi.fn()
const onClick = vi.fn()
render(<Row onLongPress={onLongPress} onClick={onClick} />)
const row = screen.getByRole('button', { name: 'row' })
touchDown(row)
act(() => { vi.advanceTimersByTime(LONG_PRESS_DELAY_MS) })
fireEvent.pointerUp(row)
fireEvent.click(row)
expect(onLongPress).toHaveBeenCalledTimes(1)
expect(onClick).not.toHaveBeenCalled()
})
it('treats a short tap as a tap', () => {
const onLongPress = vi.fn()
const onClick = vi.fn()
render(<Row onLongPress={onLongPress} onClick={onClick} />)
const row = screen.getByRole('button', { name: 'row' })
touchDown(row)
act(() => { vi.advanceTimersByTime(LONG_PRESS_DELAY_MS - 100) })
fireEvent.pointerUp(row)
fireEvent.click(row)
act(() => { vi.advanceTimersByTime(LONG_PRESS_DELAY_MS) })
expect(onLongPress).not.toHaveBeenCalled()
expect(onClick).toHaveBeenCalledTimes(1)
})
it('cancels when the finger moves, because that is a scroll', () => {
const onLongPress = vi.fn()
render(<Row onLongPress={onLongPress} onClick={vi.fn()} />)
const row = screen.getByRole('button', { name: 'row' })
touchDown(row, 10, 10)
fireEvent.pointerMove(row, { pointerType: 'touch', clientX: 10, clientY: 40 })
act(() => { vi.advanceTimersByTime(LONG_PRESS_DELAY_MS * 2) })
expect(onLongPress).not.toHaveBeenCalled()
})
it('opens once when Android also fires contextmenu during the hold', () => {
const onLongPress = vi.fn()
render(<Row onLongPress={onLongPress} onClick={vi.fn()} />)
const row = screen.getByRole('button', { name: 'row' })
touchDown(row)
act(() => { vi.advanceTimersByTime(LONG_PRESS_DELAY_MS) })
fireEvent.contextMenu(row)
expect(onLongPress).toHaveBeenCalledTimes(1)
})
it('lets a tap in the menu the press opened through, though React bubbles it back', () => {
const onPick = vi.fn()
function RowWithMenu() {
const [open, setOpen] = useState(false)
const handlers = useLongPress({ onLongPress: () => setOpen(true) })
return (
<div data-testid="row" {...handlers}>
row
{open ? createPortal(<button type="button" onClick={onPick}>pick</button>, document.body) : null}
</div>
)
}
render(<RowWithMenu />)
const row = screen.getByTestId('row')
touchDown(row)
act(() => { vi.advanceTimersByTime(LONG_PRESS_DELAY_MS) })
fireEvent.pointerUp(row)
fireEvent.click(screen.getByRole('button', { name: 'pick' }))
expect(onPick).toHaveBeenCalledTimes(1)
})
it('answers a right click on its own, without the native menu', () => {
const onLongPress = vi.fn()
render(<Row onLongPress={onLongPress} onClick={vi.fn()} />)
const row = screen.getByRole('button', { name: 'row' })
fireEvent.pointerDown(row, { pointerType: 'mouse', isPrimary: true, button: 2 })
const notPrevented = fireEvent.contextMenu(row)
expect(onLongPress).toHaveBeenCalledTimes(1)
expect(notPrevented).toBe(false)
})
})
+103
View File
@@ -0,0 +1,103 @@
import { useCallback, useEffect, useRef, type MouseEvent, type PointerEvent } from 'react'
export const LONG_PRESS_DELAY_MS = 450
const MOVE_TOLERANCE_PX = 10
export type LongPressPoint = { clientX: number; clientY: number }
type Options = {
onLongPress: (point: LongPressPoint) => void
disabled?: boolean
delayMs?: number
}
/**
* Press-and-hold for touch rows, the phone's stand-in for a right click.
*
* iOS Safari never fires `contextmenu`, so a timer on `pointerdown` does the
* work there; Android Chrome and desktop mice do fire it, and the handler
* folds that into the same callback without opening twice. A finger that
* moves past a few pixels is scrolling, not pressing, and cancels. The click
* that ends a long press is swallowed so the row's tap action does not run
* underneath the menu it just opened.
*
* Callers add `select-none [-webkit-touch-callout:none]` to the element, or
* iOS shows its own copy/lookup callout on top.
*
* Events from a portal rendered by the element's children (the menu this
* press opens is one) still bubble to it through React. Only events whose
* DOM target is inside the element count, or a tap in that menu would be
* swallowed as the end of the press that opened it.
*/
function fromInside(event: { currentTarget: HTMLElement; target: EventTarget | null }): boolean {
return event.target instanceof Node && event.currentTarget.contains(event.target)
}
export function useLongPress({ onLongPress, disabled = false, delayMs = LONG_PRESS_DELAY_MS }: Options) {
const timerRef = useRef<number | null>(null)
const startRef = useRef<LongPressPoint | null>(null)
const firedRef = useRef(false)
const callbackRef = useRef(onLongPress)
callbackRef.current = onLongPress
const clear = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current)
timerRef.current = null
}
startRef.current = null
}, [])
useEffect(() => clear, [clear])
const fire = useCallback((point: LongPressPoint) => {
clear()
firedRef.current = true
callbackRef.current(point)
}, [clear])
const onPointerDown = useCallback((event: PointerEvent<HTMLElement>) => {
if (!fromInside(event)) return
firedRef.current = false
if (disabled || event.pointerType === 'mouse' || event.isPrimary === false) return
clear()
const point = { clientX: event.clientX, clientY: event.clientY }
startRef.current = point
timerRef.current = window.setTimeout(() => fire(point), delayMs)
}, [clear, delayMs, disabled, fire])
const onPointerMove = useCallback((event: PointerEvent<HTMLElement>) => {
const start = startRef.current
if (!start) return
if (
Math.abs(event.clientX - start.clientX) > MOVE_TOLERANCE_PX
|| Math.abs(event.clientY - start.clientY) > MOVE_TOLERANCE_PX
) {
clear()
}
}, [clear])
const onContextMenu = useCallback((event: MouseEvent<HTMLElement>) => {
if (disabled || !fromInside(event)) return
event.preventDefault()
if (firedRef.current) return
fire({ clientX: event.clientX, clientY: event.clientY })
}, [disabled, fire])
const onClickCapture = useCallback((event: MouseEvent<HTMLElement>) => {
if (!firedRef.current || !fromInside(event)) return
firedRef.current = false
event.preventDefault()
event.stopPropagation()
}, [])
return {
onPointerDown,
onPointerMove,
onPointerUp: clear,
onPointerCancel: clear,
onPointerLeave: clear,
onContextMenu,
onClickCapture,
}
}
@@ -117,4 +117,17 @@ describe('useMobileViewport', () => {
expect(controller.removeListener).toHaveBeenCalledTimes(1)
expect(controller.getListenerCount()).toBe(0)
})
it('gives a wide touch browser the touch controls too, because a tablet has no hover', () => {
const controller = createMatchMediaController(false)
window.matchMedia = controller.matchMedia as typeof window.matchMedia
document.documentElement.setAttribute('data-touch-h5', 'true')
try {
render(<Probe />)
expect(screen.getByTestId('viewport-state')).toHaveTextContent('mobile')
} finally {
document.documentElement.removeAttribute('data-touch-h5')
}
})
})
+8 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { isTouchH5Document } from '../lib/touchH5'
const MOBILE_VIEWPORT_QUERY = '(max-width: 767px)'
@@ -7,6 +8,12 @@ function getInitialMobileViewport() {
return window.matchMedia(MOBILE_VIEWPORT_QUERY).matches
}
/**
* True when controls should take their touch form: narrower than a tablet, or
* any touch H5 browser. A tablet is wide enough for the desktop sizes but has
* no hover and no precise pointer, so it gets the same 44px targets and bottom
* sheets as a phone; only the page layout around them differs.
*/
export function useMobileViewport() {
const [isMobile, setIsMobile] = useState(getInitialMobileViewport)
@@ -34,5 +41,5 @@ export function useMobileViewport() {
}
}, [])
return isMobile
return isMobile || isTouchH5Document()
}
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef } from 'react'
const SESSION_LIST_AUTO_REFRESH_MS = 30_000
const SESSION_LIST_BUILDING_REFRESH_MS = 1_500
const SESSION_LIST_FOCUS_REFRESH_MIN_MS = 5_000
/**
* Keeps the session list fresh while a list is on screen: once on mount, on
* window focus / tab return (throttled), and on a slow timer — faster while
* the local index is still building. The desktop sidebar and the phone's
* session list both mount it; whichever is showing owns the refresh.
*
* Returns a function that forces a refresh now.
*/
export function useSessionListAutoRefresh(
fetchSessions: () => Promise<void>,
indexBuilding = false,
): () => Promise<void> {
const inFlightRef = useRef<Promise<void> | null>(null)
const lastStartedAtRef = useRef(0)
const minIntervalMs = indexBuilding
? SESSION_LIST_BUILDING_REFRESH_MS
: SESSION_LIST_FOCUS_REFRESH_MIN_MS
const refreshSessions = useCallback((force = false) => {
if (inFlightRef.current && !force) return inFlightRef.current
const now = Date.now()
if (!force && now - lastStartedAtRef.current < minIntervalMs) {
return Promise.resolve()
}
lastStartedAtRef.current = now
const request = Promise.resolve()
.then(() => fetchSessions())
.catch(() => undefined)
.finally(() => {
if (inFlightRef.current === request) {
inFlightRef.current = null
}
})
inFlightRef.current = request
return request
}, [fetchSessions, minIntervalMs])
useEffect(() => {
void refreshSessions(true)
const refreshIfVisible = () => {
if (!isDocumentVisible()) return
void refreshSessions()
}
window.addEventListener('focus', refreshIfVisible)
document.addEventListener('visibilitychange', refreshIfVisible)
const timer = window.setInterval(() => {
if (!isDocumentVisible()) return
void refreshSessions()
}, indexBuilding ? SESSION_LIST_BUILDING_REFRESH_MS : SESSION_LIST_AUTO_REFRESH_MS)
return () => {
window.removeEventListener('focus', refreshIfVisible)
document.removeEventListener('visibilitychange', refreshIfVisible)
window.clearInterval(timer)
}
}, [refreshSessions, indexBuilding])
return useCallback(() => refreshSessions(true), [refreshSessions])
}
export function isDocumentVisible(): boolean {
return typeof document === 'undefined' || document.visibilityState !== 'hidden'
}
+47
View File
@@ -4268,6 +4268,53 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'trajectory.request.status': "Result",
'trajectory.request.requestId': "Request ID",
'trajectory.request.purpose': "Purpose",
'mobile.home.title': "Sessions",
'mobile.home.openSearch': "Search sessions",
'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.emptyPane': "No sessions yet.",
'mobile.home.noMatches': "No sessions match.",
'mobile.group.attention': "Waiting for you",
'mobile.session.waiting': "Waiting for you",
'mobile.session.running': "Working",
'mobile.session.actionsHint': "Press and hold for more",
'mobile.session.renameTitle': "Rename session",
'mobile.session.renameLabel': "Session name",
'mobile.session.renameFailed': "Could not rename the session.",
'mobile.session.deleteFailed': "Could not delete the session.",
'mobile.nav.back': "Back",
'mobile.nav.backToSessions': "Back to sessions",
'chat.mobileSheet.sessionSettings': "Session settings",
'chat.mobileSheet.model': "Model and effort",
'mobile.approval.questionTitle': "Claude has a question for you",
'mobile.approval.position': "{index} of {total}",
'mobile.approval.viewDetails': "View details",
'mobile.approval.reasonLabel': "Reason for denying",
'mobile.approval.reasonPlaceholder': "Tell Claude what to do instead",
'mobile.approval.denyWithReason': "Deny with this reason",
'mobile.approval.denyWithReasonHint': "Deny and tell Claude why",
'mobile.approval.answer': "Answer",
'mobile.approval.reviewPlan': "Review the plan",
'mobile.approval.answerBelow': "Answer below",
'chat.messageActions.title': "Message actions",
'chat.messageActions.selectText': "Select text",
'chat.mobileSheet.title': "Tools and settings",
'mobile.diff.title': "Changes",
'mobile.diff.previous': "Previous file",
'mobile.diff.next': "Next file",
'mobile.diff.empty': "No line changes to show for this file. Open it on the computer to see it.",
'mobile.diff.failed': "Could not load the change.",
'mobile.activity.inProgress': "{count} active",
'h5Settings.connection': "Connection",
'h5Settings.connectedTo': "Connected to this computer",
'h5Settings.disconnect': "Disconnect",
'h5Settings.disconnectBody': "This phone forgets the computer and its access token. Scan the QR code on the computer again to reconnect.",
'h5Settings.activeProvider': "Model provider",
'h5Settings.desktopOnly': "Change on the computer",
'h5Settings.desktopOnlyHint': "These settings act on the computer itself, so only the computer can change them.",
'h5Settings.allSettings': "All settings",
} as const
export type TranslationKey = keyof typeof en
+47
View File
@@ -4269,4 +4269,51 @@ export const jp: Record<TranslationKey, string> = {
'trajectory.request.status': "結果",
'trajectory.request.requestId': "リクエスト ID",
'trajectory.request.purpose': "呼び出し元",
'mobile.home.title': "セッション",
'mobile.home.openSearch': "セッションを検索",
'mobile.home.closeSearch': "検索を閉じる",
'mobile.home.projectFilter': "プロジェクトで絞り込み",
'mobile.home.allProjects': "すべて",
'mobile.home.empty': "まだセッションがありません。下にタスクを書くと始められます。",
'mobile.home.emptyPane': "まだセッションがありません。",
'mobile.home.noMatches': "一致するセッションはありません。",
'mobile.group.attention': "あなたの対応待ち",
'mobile.session.waiting': "対応待ち",
'mobile.session.running': "実行中",
'mobile.session.actionsHint': "長押しでその他の操作",
'mobile.session.renameTitle': "セッション名を変更",
'mobile.session.renameLabel': "セッション名",
'mobile.session.renameFailed': "名前を変更できませんでした。",
'mobile.session.deleteFailed': "セッションを削除できませんでした。",
'mobile.nav.back': "戻る",
'mobile.nav.backToSessions': "セッション一覧に戻る",
'chat.mobileSheet.sessionSettings': "セッション設定",
'chat.mobileSheet.model': "モデルと推論強度",
'mobile.approval.questionTitle': "Claude から質問があります",
'mobile.approval.position': "{index} / {total}",
'mobile.approval.viewDetails': "詳細を見る",
'mobile.approval.reasonLabel': "拒否する理由",
'mobile.approval.reasonPlaceholder': "代わりにどうしてほしいかを伝える",
'mobile.approval.denyWithReason': "この理由で拒否",
'mobile.approval.denyWithReasonHint': "拒否して理由を伝える",
'mobile.approval.answer': "回答する",
'mobile.approval.reviewPlan': "プランを確認",
'mobile.approval.answerBelow': "下で対応",
'chat.messageActions.title': "メッセージの操作",
'chat.messageActions.selectText': "テキストを選択",
'chat.mobileSheet.title': "ツールと設定",
'mobile.diff.title': "変更",
'mobile.diff.previous': "前のファイル",
'mobile.diff.next': "次のファイル",
'mobile.diff.empty': "このファイルに表示できる行の変更はありません。パソコンで開いて確認してください。",
'mobile.diff.failed': "変更を読み込めませんでした。",
'mobile.activity.inProgress': "{count} 件進行中",
'h5Settings.connection': "接続",
'h5Settings.connectedTo': "このパソコンに接続中",
'h5Settings.disconnect': "切断",
'h5Settings.disconnectBody': "このスマホはパソコンとアクセストークンを忘れます。再接続するにはパソコンの QR コードをもう一度読み取ってください。",
'h5Settings.activeProvider': "モデルプロバイダー",
'h5Settings.desktopOnly': "パソコンで変更",
'h5Settings.desktopOnlyHint': "これらの設定はパソコン自体に作用するため、パソコンでのみ変更できます。",
'h5Settings.allSettings': "すべての設定",
}
+47
View File
@@ -4271,4 +4271,51 @@ export const kr: Record<TranslationKey, string> = {
'trajectory.request.status': "결과",
'trajectory.request.requestId': "요청 ID",
'trajectory.request.purpose': "호출 출처",
'mobile.home.title': "세션",
'mobile.home.openSearch': "세션 검색",
'mobile.home.closeSearch': "검색 닫기",
'mobile.home.projectFilter': "프로젝트별 보기",
'mobile.home.allProjects': "전체",
'mobile.home.empty': "아직 세션이 없습니다. 아래에 작업을 적으면 시작됩니다.",
'mobile.home.emptyPane': "아직 세션이 없습니다.",
'mobile.home.noMatches': "일치하는 세션이 없습니다.",
'mobile.group.attention': "확인 필요",
'mobile.session.waiting': "확인 필요",
'mobile.session.running': "실행 중",
'mobile.session.actionsHint': "길게 눌러 더 보기",
'mobile.session.renameTitle': "세션 이름 변경",
'mobile.session.renameLabel': "세션 이름",
'mobile.session.renameFailed': "이름을 바꾸지 못했습니다.",
'mobile.session.deleteFailed': "세션을 삭제하지 못했습니다.",
'mobile.nav.back': "뒤로",
'mobile.nav.backToSessions': "세션 목록으로",
'chat.mobileSheet.sessionSettings': "세션 설정",
'chat.mobileSheet.model': "모델과 추론 강도",
'mobile.approval.questionTitle': "Claude가 질문이 있습니다",
'mobile.approval.position': "{index} / {total}",
'mobile.approval.viewDetails': "자세히 보기",
'mobile.approval.reasonLabel': "거부 사유",
'mobile.approval.reasonPlaceholder': "대신 어떻게 할지 알려 주세요",
'mobile.approval.denyWithReason': "이 사유로 거부",
'mobile.approval.denyWithReasonHint': "거부하고 이유 알려 주기",
'mobile.approval.answer': "답변하기",
'mobile.approval.reviewPlan': "계획 검토",
'mobile.approval.answerBelow': "아래에서 처리",
'chat.messageActions.title': "메시지 작업",
'chat.messageActions.selectText': "텍스트 선택",
'chat.mobileSheet.title': "도구 및 설정",
'mobile.diff.title': "변경 사항",
'mobile.diff.previous': "이전 파일",
'mobile.diff.next': "다음 파일",
'mobile.diff.empty': "이 파일에는 표시할 줄 단위 변경이 없습니다. 컴퓨터에서 열어 확인하세요.",
'mobile.diff.failed': "변경 사항을 불러오지 못했습니다.",
'mobile.activity.inProgress': "{count}개 진행 중",
'h5Settings.connection': "연결",
'h5Settings.connectedTo': "이 컴퓨터에 연결됨",
'h5Settings.disconnect': "연결 해제",
'h5Settings.disconnectBody': "이 휴대폰은 컴퓨터와 액세스 토큰을 잊습니다. 다시 연결하려면 컴퓨터의 QR 코드를 다시 스캔하세요.",
'h5Settings.activeProvider': "모델 제공자",
'h5Settings.desktopOnly': "컴퓨터에서 변경",
'h5Settings.desktopOnlyHint': "이 설정은 컴퓨터 자체에 적용되므로 컴퓨터에서만 변경할 수 있습니다.",
'h5Settings.allSettings': "전체 설정",
}
+47
View File
@@ -4268,4 +4268,51 @@ export const zh: Record<TranslationKey, string> = {
'trajectory.request.status': "結果",
'trajectory.request.requestId': "請求 ID",
'trajectory.request.purpose': "呼叫來源",
'mobile.home.title': "工作階段",
'mobile.home.openSearch': "搜尋工作階段",
'mobile.home.closeSearch': "關閉搜尋",
'mobile.home.projectFilter': "依專案篩選",
'mobile.home.allProjects': "全部",
'mobile.home.empty': "還沒有工作階段。在下方描述一個任務即可開始。",
'mobile.home.emptyPane': "還沒有工作階段。",
'mobile.home.noMatches': "沒有相符的工作階段。",
'mobile.group.attention': "等你處理",
'mobile.session.waiting': "等你處理",
'mobile.session.running': "執行中",
'mobile.session.actionsHint': "長按查看更多操作",
'mobile.session.renameTitle': "重新命名工作階段",
'mobile.session.renameLabel': "工作階段名稱",
'mobile.session.renameFailed': "重新命名失敗。",
'mobile.session.deleteFailed': "刪除工作階段失敗。",
'mobile.nav.back': "返回",
'mobile.nav.backToSessions': "返回工作階段列表",
'chat.mobileSheet.sessionSettings': "工作階段設定",
'chat.mobileSheet.model': "模型與思考強度",
'mobile.approval.questionTitle': "Claude 有問題想問你",
'mobile.approval.position': "{index} / {total}",
'mobile.approval.viewDetails': "查看詳情",
'mobile.approval.reasonLabel': "拒絕的理由",
'mobile.approval.reasonPlaceholder': "告訴 Claude 應該怎麼做",
'mobile.approval.denyWithReason': "拒絕並送出理由",
'mobile.approval.denyWithReasonHint': "拒絕並告訴它原因",
'mobile.approval.answer': "回答問題",
'mobile.approval.reviewPlan': "查看並審批計畫",
'mobile.approval.answerBelow': "在下方處理",
'chat.messageActions.title': "訊息操作",
'chat.messageActions.selectText': "選取文字",
'chat.mobileSheet.title': "工具與設定",
'mobile.diff.title': "變更",
'mobile.diff.previous': "上一個檔案",
'mobile.diff.next': "下一個檔案",
'mobile.diff.empty': "這個檔案沒有可顯示的逐行變更,請在電腦上開啟查看。",
'mobile.diff.failed': "載入變更失敗。",
'mobile.activity.inProgress': "{count} 進行中",
'h5Settings.connection': "連線",
'h5Settings.connectedTo': "已連線到這台電腦",
'h5Settings.disconnect': "中斷連線",
'h5Settings.disconnectBody': "這支手機會忘記這台電腦和存取權杖。要重新連線,請在電腦上再掃一次 QR 碼。",
'h5Settings.activeProvider': "模型提供商",
'h5Settings.desktopOnly': "需在電腦上修改",
'h5Settings.desktopOnlyHint': "這些設定作用於電腦本身,只能在電腦上修改。",
'h5Settings.allSettings': "全部設定",
}
+47
View File
@@ -4267,4 +4267,51 @@ export const zh: Record<TranslationKey, string> = {
'trajectory.request.status': "结果",
'trajectory.request.requestId': "请求 ID",
'trajectory.request.purpose': "调用来源",
'mobile.home.title': "会话",
'mobile.home.openSearch': "搜索会话",
'mobile.home.closeSearch': "关闭搜索",
'mobile.home.projectFilter': "按项目筛选",
'mobile.home.allProjects': "全部",
'mobile.home.empty': "还没有会话。在下方描述一个任务就能开始。",
'mobile.home.emptyPane': "还没有会话。",
'mobile.home.noMatches': "没有匹配的会话。",
'mobile.group.attention': "等你处理",
'mobile.session.waiting': "等你处理",
'mobile.session.running': "运行中",
'mobile.session.actionsHint': "长按查看更多操作",
'mobile.session.renameTitle': "重命名会话",
'mobile.session.renameLabel': "会话名称",
'mobile.session.renameFailed': "重命名失败。",
'mobile.session.deleteFailed': "删除会话失败。",
'mobile.nav.back': "返回",
'mobile.nav.backToSessions': "返回会话列表",
'chat.mobileSheet.sessionSettings': "会话设置",
'chat.mobileSheet.model': "模型与思考强度",
'mobile.approval.questionTitle': "Claude 有问题想问你",
'mobile.approval.position': "{index} / {total}",
'mobile.approval.viewDetails': "查看详情",
'mobile.approval.reasonLabel': "拒绝的理由",
'mobile.approval.reasonPlaceholder': "告诉 Claude 应该怎么做",
'mobile.approval.denyWithReason': "拒绝并发送理由",
'mobile.approval.denyWithReasonHint': "拒绝并告诉它原因",
'mobile.approval.answer': "回答问题",
'mobile.approval.reviewPlan': "查看并审批计划",
'mobile.approval.answerBelow': "在下方处理",
'chat.messageActions.title': "消息操作",
'chat.messageActions.selectText': "选择文字",
'chat.mobileSheet.title': "工具与设置",
'mobile.diff.title': "改动",
'mobile.diff.previous': "上一个文件",
'mobile.diff.next': "下一个文件",
'mobile.diff.empty': "这个文件没有可显示的行级改动,请在电脑上打开查看。",
'mobile.diff.failed': "加载改动失败。",
'mobile.activity.inProgress': "{count} 进行中",
'h5Settings.connection': "连接",
'h5Settings.connectedTo': "已连接到这台电脑",
'h5Settings.disconnect': "断开连接",
'h5Settings.disconnectBody': "这台手机会忘掉这台电脑和访问令牌。要重新连接,请在电脑上再扫一次二维码。",
'h5Settings.activeProvider': "模型提供商",
'h5Settings.desktopOnly': "需在电脑上修改",
'h5Settings.desktopOnlyHint': "这些设置作用于电脑本身,只能在电脑上修改。",
'h5Settings.allSettings': "全部设置",
}
+24
View File
@@ -0,0 +1,24 @@
import type { TranslationKey } from '../i18n'
type Translate = (key: TranslationKey, params?: Record<string, string | number>) => string
/**
* "Just now / 5 min ago / 3 days ago", falling back to a short date after a
* month. Session rows on the desktop sidebar and the phone's session list
* read the same way because they share this.
*/
export function formatRelativeTime(dateStr: string, t: Translate, now = Date.now()): string {
const date = new Date(dateStr)
const timestamp = date.getTime()
if (!Number.isFinite(timestamp)) return ''
const diff = now - timestamp
const min = Math.floor(diff / 60000)
if (min < 1) return t('session.timeJustNow')
if (min < 60) return t('session.timeMinutes', { n: min })
const hr = Math.floor(min / 60)
if (hr < 24) return t('session.timeHours', { n: hr })
const day = Math.floor(hr / 24)
if (day < 30) return t('session.timeDays', { n: day })
return new Intl.DateTimeFormat(undefined, { month: 'numeric', day: 'numeric' }).format(date)
}
+6 -6
View File
@@ -437,12 +437,12 @@ describe('touch-H5 stylesheet contract', () => {
expect(css).toMatch(/html\[data-touch-h5\]\[data-touch-h5-keyboard\] \.app-shell-viewport \{\s*\n\s*padding-bottom: 0px;/)
})
it('keeps message action bars visible in the mobile shell without relying on touch detection', () => {
expect(css).toMatch(/\.app-shell--mobile \[data-message-actions\],\s*\nhtml\[data-touch-h5\] \[data-message-actions\] \{\s*\n\s*opacity: 1;\s*\n\s*pointer-events: auto;/)
// 2.75rem = 44px, the platform minimum for primary touch targets. These
// buttons shipped at 40px because IconButton's size doc had the 40/44
// tiers reversed.
expect(css).toMatch(/\.app-shell--mobile \[data-message-actions\] button \{\s*\n\s*width: 2\.75rem;/)
it('does not force the hover action bars open on touch: the phone holds a message for its actions', () => {
// The bars were kept visible under every message on a phone, a row of
// icons per message. They are no longer drawn there (useMessageActionMenu),
// and a rule forcing them visible would bring the row back the moment a
// bar renders on a touch layout again.
expect(css).not.toMatch(/\[data-message-actions\]/)
})
it('disables paint skipping for the trace-window rows too', () => {
+75
View File
@@ -1201,6 +1201,81 @@ describe('ActiveSession task polling', () => {
}, { timeout: 4000 })
})
it('on a phone, publishes the activity to the top bar pill instead of throwing the sheet over the chat', async () => {
viewportMocks.isMobile = true
const sessionId = 'activity-phone-session'
useCLITaskStore.setState({ fetchSessionTasks: vi.fn().mockResolvedValue(undefined) })
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Phone Activity Session',
createdAt: '2026-05-07T00:00:00.000Z',
modifiedAt: '2026-05-07T00:00:00.000Z',
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Phone Activity Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [],
chatState: 'thinking',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
backgroundAgentTasks: {},
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
act(() => {
useCLITaskStore.setState({
sessionId,
tasks: [{
id: 'task-1',
subject: 'Draft implementation plan',
description: 'Create the first activity row',
status: 'in_progress',
blocks: [],
blockedBy: [],
taskListId: sessionId,
}],
completedAndDismissed: false,
})
})
await waitFor(() => {
expect(useActivityPanelStore.getState().mobileSummaryBySession[sessionId]).toEqual({ visible: true, count: 1 })
})
expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(false)
act(() => useActivityPanelStore.getState().open(sessionId))
expect(await screen.findByTestId('session-activity-sheet')).toBeInTheDocument()
act(() => useActivityPanelStore.getState().close())
})
it('auto-opens for current activity and seals unfinished tasks when the turn becomes idle', async () => {
const sessionId = 'activity-auto-open-session'
const fetchSessionTasks = vi.fn().mockResolvedValue(undefined)
+15 -3
View File
@@ -626,6 +626,14 @@ export function ActiveSession({ sessionId, active = true }: { sessionId?: string
workflowRuns,
])
const hasVisibleActivity = activityModel ? hasVisibleSessionActivity(activityModel) : false
const activityBadgeCount = activityModel?.badgeCount ?? 0
// The phone's top bar lives outside this page; it draws the activity pill
// from what is published here.
useEffect(() => {
if (!isMobileLayout || !active || !activeTabId) return
useActivityPanelStore.getState().setMobileSummary(activeTabId, { visible: hasVisibleActivity, count: activityBadgeCount })
}, [active, activeTabId, activityBadgeCount, hasVisibleActivity, isMobileLayout])
const hasAutoOpenActivity = activityModel ? activityModel.badgeCount > 0 : false
useEffect(() => {
@@ -639,7 +647,10 @@ export function ActiveSession({ sessionId, active = true }: { sessionId?: string
return
}
if (!state.hadAutoOpenActivity && hasAutoOpenActivity && !isActivityPanelOpen) {
// Not on a phone: there the panel is a sheet over the conversation, and
// the top bar's activity pill already says work started, without covering
// what the agent is writing.
if (!state.hadAutoOpenActivity && hasAutoOpenActivity && !isActivityPanelOpen && !isMobileLayout) {
openActivityPanel(activeTabId)
}
state.hadAutoOpenActivity = hasAutoOpenActivity
@@ -648,6 +659,7 @@ export function ActiveSession({ sessionId, active = true }: { sessionId?: string
activeTabType,
hasAutoOpenActivity,
isActivityPanelOpen,
isMobileLayout,
openActivityPanel,
])
@@ -989,7 +1001,7 @@ export function ActiveSession({ sessionId, active = true }: { sessionId?: string
</div>
) : (
<TrajectoryLinkContext.Provider value={isMobileLayout ? null : trajectoryLink}>
<MessageList sessionId={activeTabId ?? undefined} compact={showRightPanel} mobileLayout={isMobileLayout} />
<MessageList sessionId={activeTabId ?? undefined} compact={showRightPanel} mobileLayout={isMobileLayout} decisionsInComposer={isMobileLayout} />
</TrajectoryLinkContext.Provider>
)}
</div>
@@ -1018,7 +1030,7 @@ export function ActiveSession({ sessionId, active = true }: { sessionId?: string
onOpenMember={handleOpenTeamMember}
onStopBackgroundTask={handleStopBackgroundTask}
stoppingBackgroundTaskIds={stoppingBackgroundTaskIds}
placement="overlay"
placement="sheet"
/>
) : null}
+24
View File
@@ -377,6 +377,30 @@ 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', () => {
mocks.isMobile = true
render(<EmptySession mobileHome={<div>session list</div>} />)
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'))
})
it('ignores the phone home slot on a desktop window', () => {
mocks.isMobile = false
render(<EmptySession mobileHome={<div>session list</div>} />)
expect(screen.queryByTestId('mobile-home')).not.toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument()
})
it('uses compact composer controls on phone-sized H5 browsers', async () => {
mocks.isMobile = true
+101 -27
View File
@@ -1,7 +1,7 @@
import { isComposerReferenceVisible, isComposerSlashCommandVisible } from '@/lib/composerCapabilityVisibility'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
import { useDismissable } from '@/hooks/useDismissable'
import { ArrowUp, Plus } from 'lucide-react'
import { ArrowUp, Cpu, Plus, ShieldCheck } from 'lucide-react'
import { BrandSeal } from '@/components/composite/BrandSeal'
import { NewSessionStarter } from '@/components/layout/NewSessionStarter'
import { projectTitle } from '@/components/layout/sidebarTaskGroups'
@@ -22,7 +22,8 @@ import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { RepositoryLaunchControls } from '@/components/chat/RepositoryLaunchControls'
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
import { PermissionModeSelector, type PermissionModeSelectorHandle } from '../components/controls/PermissionModeSelector'
import { PERMISSION_MODE_LABEL_KEYS } from '../components/controls/permissionModeState'
import { ModelSelector, type ModelSelectorHandle } from '../components/controls/ModelSelector'
import { AttachmentGallery } from '../components/chat/AttachmentGallery'
import { ComposerDropOverlay } from '../components/chat/ComposerDropOverlay'
@@ -58,6 +59,7 @@ import {
findMentionRanges,
insertMentionIntoText,
type ComposerMention,
type NewComposerMention,
} from '../lib/composerMentions'
import {
appendAgentSlashCommands,
@@ -73,6 +75,7 @@ import {
resolveSlashUiAction,
} from '../components/chat/composerUtils'
import { ComposerCapabilityMenu } from '@/components/chat/ComposerCapabilityMenu'
import { MobileComposerSheet, type MobileComposerSetting } from '@/components/chat/MobileComposerSheet'
import { useCapabilityMenu } from '@/components/chat/useCapabilityMenu'
import type { AttachmentRef } from '../types/chat'
import type { PermissionMode } from '../types/settings'
@@ -123,7 +126,15 @@ function resolveCreateSessionErrorMessage(error: unknown, t: Translate): string
const EMPTY_COMPOSER_REFERENCES: ComposerReferenceCandidate[] = []
export function EmptySession() {
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
}
export function EmptySession({ mobileHome }: EmptySessionProps = {}) {
const t = useTranslation()
const [input, setInput] = useState('')
const [mentions, setMentions] = useState<ComposerMention[]>([])
@@ -152,6 +163,7 @@ export function EmptySession() {
const panelRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const modelSelectorRef = useRef<ModelSelectorHandle>(null)
const permissionSelectorRef = useRef<PermissionModeSelectorHandle>(null)
const plusMenuRef = useRef<HTMLDivElement>(null)
const slashMenuRef = useRef<HTMLDivElement>(null)
const fileSearchRef = useRef<ComposerReferenceMenuHandle>(null)
@@ -212,12 +224,19 @@ export function EmptySession() {
// 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
}, [])
useDismissable({
open: plusMenuOpen,
// On a phone the + opens a sheet that closes itself; see ChatInput.
open: plusMenuOpen && !isMobileComposer,
refs: [plusMenuRef],
onDismiss: () => setPlusMenuOpen(false),
})
@@ -698,6 +717,43 @@ export function EmptySession() {
})
}
const insertSelectedFileMention = (mention: NewComposerMention) => {
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? input.length
const inserted = insertMentionIntoText(input, mentions, cursorPos, cursorPos, mention)
setInput(inserted.text)
setMentions(inserted.mentions)
requestAnimationFrame(() => {
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(inserted.cursorPos)
})
}
// See ChatInput: the phone's + sheet holds the controls that left the
// toolbar, and a row hands over to that control's own sheet.
const openFromComposerSheet = (open: () => void) => {
setPlusMenuOpen(false)
requestAnimationFrame(open)
}
const mobileComposerSettings: MobileComposerSetting[] = isMobileComposer
? [
{
key: 'permission',
icon: <ShieldCheck size={16} strokeWidth={1.75} aria-hidden="true" />,
label: t('permMode.executionPermissions'),
value: t(PERMISSION_MODE_LABEL_KEYS[draftPermissionMode]),
onSelect: () => openFromComposerSheet(() => permissionSelectorRef.current?.open()),
},
{
key: 'model',
icon: <Cpu size={16} strokeWidth={1.75} aria-hidden="true" />,
label: t('chat.mobileSheet.model'),
value: draftModelLabel,
disabled: isSubmitting,
onSelect: () => openFromComposerSheet(() => modelSelectorRef.current?.open()),
},
]
: []
// The "+" capability menu: the shared hook owns data and navigation actions,
// these handlers are only the composer-local edits. Kept identical to
// ChatInput's block on purpose — the two composers are one control.
@@ -758,6 +814,11 @@ export function EmptySession() {
// which puts it just above the middle of the page. On a phone it stays
// docked to the bottom edge, above the keyboard.
<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}
</div>
) : (
<div className={`flex flex-col items-center text-center ${
isMobileComposer
? 'flex-1 justify-center px-6 pb-[230px] pt-10'
@@ -779,11 +840,14 @@ export function EmptySession() {
</p>
</div>
</div>
)}
<div
data-testid="empty-session-composer-shell"
className={`flex justify-center ${
isMobileComposer
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
? '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'
}`}
@@ -896,7 +960,7 @@ export function EmptySession() {
// `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 min-h-[72px] overflow-y-auto px-2.5 pb-1 pt-2 text-[var(--color-text-primary)] ${
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)] ${
isMobileComposer ? 'max-h-[132px]' : 'max-h-[200px]'
}`}
aria={{
@@ -934,33 +998,39 @@ export function EmptySession() {
<Plus size={isMobileComposer ? 18 : 16} strokeWidth={1.75} aria-hidden="true" />
</button>
{plusMenuOpen && (
{plusMenuOpen && !isMobileComposer && (
<ComposerCapabilityMenu
cwd={workDir}
referencesLoading={referenceCurrent?.loading ?? true}
referencesError={referenceCurrent?.error}
onSelectFile={mention => {
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? input.length
const inserted = insertMentionIntoText(input, mentions, cursorPos, cursorPos, mention)
setInput(inserted.text)
setMentions(inserted.mentions)
requestAnimationFrame(() => {
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(inserted.cursorPos)
})
}}
onSelectFile={insertSelectedFileMention}
id={capabilityMenuId}
sections={capabilityMenu.sections}
onAction={capabilityMenu.onAction}
onClose={() => setPlusMenuOpen(false)}
mobile={isMobileComposer}
/>
)}
{isMobileComposer && (
<MobileComposerSheet
open={plusMenuOpen}
onClose={() => setPlusMenuOpen(false)}
menuId={capabilityMenuId}
settings={mobileComposerSettings}
sections={capabilityMenu.sections}
cwd={workDir}
referencesLoading={referenceCurrent?.loading ?? true}
referencesError={referenceCurrent?.error}
onSelectFile={insertSelectedFileMention}
onAction={capabilityMenu.onAction}
/>
)}
</div>
<PermissionModeSelector
ref={permissionSelectorRef}
workDir={workDir}
compact={isMobileComposer}
trigger={isMobileComposer ? 'elevatedOnly' : 'chip'}
value={draftPermissionMode}
onChange={setDraftPermissionMode}
/>
@@ -984,14 +1054,18 @@ export function EmptySession() {
hidden={dictationLive}
className={`${isMobileComposer ? 'flex min-w-0 flex-1 items-center justify-end gap-1' : 'flex shrink-0 items-center gap-1'}`}
>
<ContextUsageIndicator
chatState="idle"
messageCount={0}
runtimeSelectionKey={draftRuntimeSelectionKey}
fallbackModelLabel={draftModelLabel}
draft
compact={isMobileComposer}
/>
{/* A draft has used none of its context; on a phone that is not
worth one of the toolbar's few touch targets. */}
{!isMobileComposer && (
<ContextUsageIndicator
chatState="idle"
messageCount={0}
runtimeSelectionKey={draftRuntimeSelectionKey}
fallbackModelLabel={draftModelLabel}
draft
compact={isMobileComposer}
/>
)}
<ModelSelector ref={modelSelectorRef} runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} compact={isMobileComposer} />
<VoiceInputButton dictation={dictation} blocked={isSubmitting} mobile={isMobileComposer} />
{/* Kept identical to ChatInput's send button — same
+3 -5
View File
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -80,13 +80,11 @@ describe('Voice Input settings tab wiring', () => {
expect(fresh.useUIStore.getState().activeSettingsTab).toBe('voice')
})
it('keeps voice out of the browser shell: a stored voice tab falls back to model settings', async () => {
it('keeps voice out of the browser shell: a stored voice tab opens the settings list instead', async () => {
useUIStore.setState({ activeSettingsTab: 'voice' })
render(<H5Settings />)
expect(await screen.findByText('provider-panel')).toBeInTheDocument()
expect(await screen.findByTestId('h5-settings-providers')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Voice Input' })).not.toBeInTheDocument()
expect(within(screen.getByRole('navigation', { name: 'Settings' })).getAllByRole('button')).toHaveLength(2)
await waitFor(() => expect(useUIStore.getState().activeSettingsTab).toBe('providers'))
})
})
@@ -524,6 +524,8 @@ describe('SubagentRunPage', () => {
expect(screen.getByTestId('session-header')).toHaveClass('px-4', 'py-2.5')
expect(screen.getByTestId('agent-run-conversation-column')).toHaveClass('flex-1')
expect(screen.queryByTestId('conversation-navigator')).not.toBeInTheDocument()
// The phone shell's top bar owns Back; the page does not draw a second one.
expect(screen.queryByRole('button', { name: 'Back to parent session' })).not.toBeInTheDocument()
})
it('does not replace authoritative activity with a partial transcript tail', async () => {
+3 -1
View File
@@ -772,7 +772,9 @@ function AgentSessionView({
<SessionChatHeader
title={title}
compact={isMobileLayout}
leading={(
// On a phone the shell's top bar already carries Back (and the system
// back gesture drives it); a second one here said the same thing twice.
leading={isMobileLayout ? undefined : (
<Button
variant="ghost"
size="sm"
+29 -6
View File
@@ -26,13 +26,13 @@ beforeEach(() => {
vi.spyOn(providersApi, 'updateSettings').mockResolvedValue({ ok: true })
})
afterEach(() => { cleanup(); vi.restoreAllMocks() })
it('limits browser navigation to providers and general, with local appearance and shared agent preferences', async () => {
it('shows one grouped list: connection, provider, phone-editable preferences, and computer-only settings read-only', async () => {
useUIStore.setState({ activeSettingsTab: 'terminal' })
render(<H5Settings />)
const nav = within(screen.getByRole('navigation', { name: 'Settings' }))
expect(nav.getAllByRole('button')).toHaveLength(2)
// No tab strip of two pills any more, and nothing desktop-only opens.
expect(screen.queryByRole('navigation', { name: 'Settings' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Terminal' })).not.toBeInTheDocument()
fireEvent.click(nav.getByRole('button', { name: 'General' }))
expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument()
expect(screen.getByText('Appearance and interface language apply only to this browser.')).toBeInTheDocument()
const update = vi.spyOn(settingsApi, 'updateUser').mockResolvedValue({ ok: true })
fireEvent.change(screen.getByLabelText('Output Style'), { target: { value: 'Learning' } })
@@ -42,6 +42,29 @@ it('limits browser navigation to providers and general, with local appearance an
fireEvent.change(screen.getByLabelText('Reasoning effort'), { target: { value: 'low' } })
await waitFor(() => expect(effort).toHaveBeenCalledWith('low'))
expect(screen.queryByLabelText(/ngrok Authtoken/)).not.toBeInTheDocument()
// The computer-only settings are listed so the phone knows they exist, and
// offer nothing to press.
const desktopOnly = screen.getByTestId('h5-settings-desktop-only')
expect(desktopOnly).toHaveTextContent('MCP')
expect(within(desktopOnly).queryAllByRole('button')).toHaveLength(0)
})
it('opens the model provider page from the list and comes back', async () => {
useUIStore.setState({ activeSettingsTab: 'general' })
render(<H5Settings />)
fireEvent.click(screen.getByTestId('h5-settings-providers'))
expect(await screen.findByTestId('provider-fixture-provider')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'All settings' }))
expect(screen.getByTestId('h5-settings-providers')).toBeInTheDocument()
})
it('opens on the list even when the providers page was the last one seen', () => {
useUIStore.setState({ activeSettingsTab: 'providers' })
render(<H5Settings />)
expect(screen.getByTestId('h5-settings-providers')).toBeInTheDocument()
})
it('honours a link that asks for the providers page', async () => {
useUIStore.setState({ pendingSettingsTab: 'providers' })
render(<H5Settings />)
expect(await screen.findByTestId('provider-fixture-provider')).toBeInTheDocument()
})
it('edits saved providers without reading or overwriting stored keys or global settings', async () => {
const update = vi.spyOn(providersApi, 'update').mockResolvedValue({ provider: saved })
@@ -94,10 +117,10 @@ it('changes browser appearance without writing connected computer settings', asy
})
it('routes the actual Settings page to the browser-safe panels', async () => {
useUIStore.setState({ activeSettingsTab: 'general' })
render(<Settings />)
const nav = within(screen.getByRole('navigation', { name: 'Settings' }))
expect(nav.getAllByRole('button')).toHaveLength(2)
expect(screen.queryByTestId('settings-navigation')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('h5-settings-providers'))
expect(await screen.findByTestId('provider-fixture-provider')).toBeInTheDocument()
})
+142 -17
View File
@@ -1,27 +1,152 @@
import { useEffect } from 'react'
import { useTranslation } from '@/i18n'
import { useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, Lock, Server, Unplug } from 'lucide-react'
import { useTranslation, type TranslationKey } from '@/i18n'
import { useUIStore } from '@/stores/uiStore'
import { SettingsPill } from '@/components/settings/SettingsSection'
import { useProviderStore } from '@/stores/providerStore'
import { SettingsGroup, SettingsRow, SettingsSection } from '@/components/settings/SettingsSection'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { clearStoredH5Connection, getServerBaseUrl } from '@/lib/desktopRuntime'
import { ProviderSettings } from './ProviderSettings'
import { H5GeneralSettings } from './H5GeneralSettings'
/**
* The settings a computer keeps that a phone can see but not change: they
* touch the computer itself (its MCP servers, its screen, its H5 door), so the
* server only lets the computer edit them. Listing them says they exist and
* where to change them, instead of leaving the phone unaware of them.
*/
const DESKTOP_ONLY_SETTINGS: TranslationKey[] = [
'settings.tab.h5Access',
'settings.tab.mcp',
'settings.tab.skills',
'settings.tab.plugins',
'settings.tab.agents',
'settings.tab.memory',
'settings.tab.adapters',
'settings.tab.computerUse',
'settings.tab.voice',
]
/**
* Settings on a phone or tablet: one grouped list, everything visible. Which
* computer this is connected to sits on top (with a way to disconnect), the
* model provider opens its own page, the preferences a phone may change
* follow, and the computer-only ones close the list, read-only.
*/
export function H5Settings() {
const t = useTranslation()
const active = useUIStore((state) => state.activeSettingsTab)
const pending = useUIStore((state) => state.pendingSettingsTab)
const selected = (pending ?? active) === 'general' ? 'general' : 'providers'
const providerName = useProviderStore((state) => (
state.providers.find((provider) => provider.id === state.activeId)?.name ?? null
))
const fetchProviders = useProviderStore((state) => state.fetchProviders)
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
// Settings always opens on the list. The provider page is a step into it,
// kept here rather than in the remembered settings tab, so coming back to
// Settings later starts from the list again. A link asking for the providers
// page (no provider configured yet) still lands there.
const [showingProviders, setShowingProviders] = useState(pending === 'providers')
useEffect(() => {
useUIStore.getState().setActiveSettingsTab(selected)
if (pending) useUIStore.getState().setPendingSettingsTab(null)
}, [pending, selected])
return <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-[var(--color-surface)]">
<nav aria-label={t('sidebar.settings')} className="flex shrink-0 [&_button]:min-h-11 flex-wrap gap-2 border-b border-[var(--color-border)] p-3">
<SettingsPill selected={selected === 'providers'} onClick={() => useUIStore.getState().setActiveSettingsTab('providers')}>{t('settings.tab.providers')}</SettingsPill>
<SettingsPill selected={selected === 'general'} onClick={() => useUIStore.getState().setActiveSettingsTab('general')}>{t('settings.tab.general')}</SettingsPill>
</nav>
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-4 py-5 sm:px-6">
<p className="mb-5 text-xs leading-5 text-[var(--color-text-tertiary)]">{t('h5Settings.scope')}</p>
{selected === 'providers' ? <ProviderSettings browserMode /> : <H5GeneralSettings />}
if (!pending) return
setShowingProviders(pending === 'providers')
useUIStore.getState().setPendingSettingsTab(null)
}, [pending])
useEffect(() => {
void fetchProviders().catch(() => undefined)
}, [fetchProviders])
const openList = () => setShowingProviders(false)
if (showingProviders) {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-[var(--color-surface)]">
<div className="shrink-0 border-b border-[var(--color-border)] px-1 py-1">
<Button
variant="ghost"
size="lg"
icon={<ChevronLeft size={18} strokeWidth={1.75} aria-hidden="true" />}
onClick={openList}
>
{t('h5Settings.allSettings')}
</Button>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-4 py-5">
<ProviderSettings browserMode />
</div>
</div>
)
}
const host = (() => {
try {
return new URL(getServerBaseUrl()).host
} catch {
return getServerBaseUrl()
}
})()
return (
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden bg-[var(--color-surface)] px-4 py-5">
<SettingsSection className="mt-0" title={t('h5Settings.connection')}>
<SettingsGroup>
<SettingsRow title={t('h5Settings.connectedTo')} description={<span className="font-mono">{host}</span>}>
<Button
variant="danger-ghost"
size="md"
icon={<Unplug size={16} strokeWidth={1.75} aria-hidden="true" />}
onClick={() => setConfirmDisconnect(true)}
>
{t('h5Settings.disconnect')}
</Button>
</SettingsRow>
</SettingsGroup>
</SettingsSection>
<SettingsSection title={t('settings.tab.providers')}>
<SettingsGroup>
<button
type="button"
data-testid="h5-settings-providers"
onClick={() => setShowingProviders(true)}
className="flex min-h-12 w-full items-center gap-3 px-4 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)] active:bg-[var(--color-surface-hover)]"
>
<Server size={18} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-secondary)]" />
<span className="min-w-0 flex-1 truncate text-[14px] text-[var(--color-text-primary)]">{t('h5Settings.activeProvider')}</span>
<span className="max-w-[45%] truncate text-[13px] text-[var(--color-text-tertiary)]">{providerName ?? ''}</span>
<ChevronRight size={16} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
</SettingsGroup>
</SettingsSection>
<H5GeneralSettings />
<SettingsSection title={t('h5Settings.desktopOnly')} description={t('h5Settings.desktopOnlyHint')}>
<SettingsGroup data-testid="h5-settings-desktop-only">
{DESKTOP_ONLY_SETTINGS.map((key) => (
<div key={key} className="flex min-h-11 items-center gap-3 px-4 text-[14px] text-[var(--color-text-tertiary)]">
<span className="min-w-0 flex-1 truncate">{t(key)}</span>
<Lock size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0" />
</div>
))}
</SettingsGroup>
</SettingsSection>
<ConfirmDialog
open={confirmDisconnect}
onClose={() => setConfirmDisconnect(false)}
onConfirm={() => {
clearStoredH5Connection()
window.location.reload()
}}
title={t('h5Settings.disconnect')}
body={t('h5Settings.disconnectBody')}
confirmLabel={t('h5Settings.disconnect')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
/>
</div>
</div>
)
}
+27
View File
@@ -1,8 +1,22 @@
import { create } from 'zustand'
import type { ActivitySectionId } from '../components/activity/sessionActivityModel'
/**
* What the phone's top bar needs to draw the activity pill: whether the
* session has any parallel work to show, and how much of it needs a look
* (running, pending or failed). The session page derives it from the same
* model the panel renders and publishes it here, because the top bar sits
* outside that page.
*/
export type MobileActivitySummary = {
visible: boolean
count: number
}
type ActivityPanelStore = {
openSessionId: string | null
mobileSummaryBySession: Record<string, MobileActivitySummary | undefined>
setMobileSummary: (sessionId: string, summary: MobileActivitySummary | null) => void
selectedSectionBySession: Record<string, ActivitySectionId | undefined>
dismissedBackgroundTaskKeysBySession: Record<string, string[] | undefined>
@@ -18,6 +32,19 @@ type ActivityPanelStore = {
export const useActivityPanelStore = create<ActivityPanelStore>((set, get) => ({
openSessionId: null,
mobileSummaryBySession: {},
setMobileSummary: (sessionId, summary) =>
set((state) => {
const current = state.mobileSummaryBySession[sessionId]
if (!summary) {
if (!current) return state
const next = { ...state.mobileSummaryBySession }
delete next[sessionId]
return { mobileSummaryBySession: next }
}
if (current && current.visible === summary.visible && current.count === summary.count) return state
return { mobileSummaryBySession: { ...state.mobileSummaryBySession, [sessionId]: summary } }
}),
selectedSectionBySession: {},
dismissedBackgroundTaskKeysBySession: {},
-56
View File
@@ -1877,10 +1877,6 @@ button, input, textarea, select, a, [role="button"] {
overflow-x: hidden;
}
.app-shell-backdrop {
background: var(--color-overlay-scrim);
}
.sidebar-shell {
position: relative;
width: var(--sidebar-width);
@@ -1948,28 +1944,6 @@ body.sidebar-resizing * {
cursor: col-resize !important;
}
.app-shell--mobile .sidebar-shell {
position: fixed;
inset: 0 auto 0 0;
z-index: 50;
width: min(88vw, var(--sidebar-width));
max-width: var(--sidebar-width);
overflow: visible;
transform: translateX(-100%);
transition:
transform var(--motion-sidebar-duration) var(--motion-sidebar-easing),
width var(--motion-sidebar-duration) var(--motion-sidebar-easing);
}
.app-shell--mobile .sidebar-shell[data-state="closed"] {
pointer-events: none;
}
.app-shell--mobile .sidebar-shell[data-state="open"] {
transform: translateX(0);
pointer-events: auto;
}
.sidebar-panel {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
@@ -1985,13 +1959,6 @@ body.sidebar-resizing * {
min-width: var(--sidebar-rail-width);
}
.app-shell--mobile .sidebar-panel,
.app-shell--mobile .sidebar-panel[data-state="closed"] {
width: min(88vw, var(--sidebar-width));
min-width: min(88vw, var(--sidebar-width));
box-shadow: var(--shadow-dropdown);
}
.sidebar-toggle-button {
color: var(--color-text-secondary-a72);
background: transparent;
@@ -2634,29 +2601,6 @@ html[data-touch-h5][data-touch-h5-keyboard] .app-shell-viewport {
padding-bottom: 0px;
}
/* Message action bars (copy / branch) are hover-revealed on desktop. Keep
them visible whenever the browser is in the narrow H5 shell, even if an
embedded browser reports no coarse pointer, and retain the touch marker as
coverage for wider tablets. */
.app-shell--mobile [data-message-actions],
html[data-touch-h5] [data-message-actions] {
opacity: 1;
pointer-events: auto;
}
.app-shell--mobile [data-message-actions] {
height: 2.75rem;
}
.app-shell--mobile [data-message-actions] > div,
.app-shell--mobile [data-message-actions] button {
min-height: 2.75rem;
}
.app-shell--mobile [data-message-actions] button {
width: 2.75rem;
}
/* Agent list rows reveal their edit / delete / override buttons on hover, which
never fires on a touchscreen — the controls would be unreachable there. */
.app-shell--mobile [data-agent-row-actions],
@@ -216,3 +216,71 @@ describe('read-only session chat activity status', () => {
expect(getSessionChatActivityState(sessionId)).toBe('idle')
})
})
describe('live session activity for the phone session list', () => {
afterEach(() => {
__resetWebSocketHandlerStateForTests()
mock.restore()
})
async function getLiveStatus(): Promise<Array<{ id: string; activityState: string }>> {
const { handleSessionsApi } = await import('../api/sessions.js')
const url = new URL('http://127.0.0.1/api/sessions/live-status')
const response = await handleSessionsApi(new Request(url), url, ['api', 'sessions', 'live-status'])
expect(response.status).toBe(200)
const body = (await response.json()) as { sessions: Array<{ id: string; activityState: string }> }
return body.sessions
}
it('lists working and waiting sessions in one answer and leaves idle ones out', async () => {
const running = `live-running-${crypto.randomUUID()}`
const waiting = `live-waiting-${crypto.randomUUID()}`
const idle = `live-idle-${crypto.randomUUID()}`
__markActiveTurnForTests(running)
__markActiveTurnForTests(waiting)
spyOn(conversationService, 'getActiveSessions').mockReturnValue([running, waiting, idle])
spyOn(conversationService, 'getPendingPermissionRequests').mockImplementation((id: string) => (
id === waiting ? [{ requestId: 'permission-1', toolName: 'Bash', input: { command: 'ls' } }] : []
))
const live = await getLiveStatus()
expect(live).toContainEqual({ id: running, activityState: 'running' })
expect(live).toContainEqual({ id: waiting, activityState: 'waiting' })
expect(live.some((entry) => entry.id === idle)).toBe(false)
})
it('finds a turn that is running before its CLI process is tracked', async () => {
const sessionId = `live-turn-only-${crypto.randomUUID()}`
__markActiveTurnForTests(sessionId)
spyOn(conversationService, 'getActiveSessions').mockReturnValue([])
expect(await getLiveStatus()).toContainEqual({ id: sessionId, activityState: 'running' })
})
it('reports a team worker through its lead instead of as a row of its own', async () => {
const lead = `live-lead-${crypto.randomUUID()}`
const worker = `live-worker-${crypto.randomUUID()}`
spyOn(conversationService, 'getActiveSessions').mockReturnValue([lead, worker])
spyOn(conversationService, 'isTeamWorkerSession').mockImplementation((id: string) => id === worker)
spyOn(conversationService, 'getPendingPermissionRequests').mockImplementation(() => [
{ requestId: 'permission-1', toolName: 'Bash', input: { command: 'ls' } },
])
const live = await getLiveStatus()
expect(live).toContainEqual({ id: lead, activityState: 'waiting' })
expect(live.some((entry) => entry.id === worker)).toBe(false)
})
it('rejects writes to the live-status collection route', async () => {
const { handleSessionsApi } = await import('../api/sessions.js')
const url = new URL('http://127.0.0.1/api/sessions/live-status')
const response = await handleSessionsApi(
new Request(url, { method: 'POST' }),
url,
['api', 'sessions', 'live-status'],
)
expect(response.status).toBe(405)
})
})
+12
View File
@@ -39,6 +39,7 @@ import {
closeSessionConnection,
ensureCliSessionStartedForControl,
getSlashCommands,
listLiveSessionActivity,
} from '../ws/handler.js'
import { listSkillSlashCommands, type SkillSlashCommand } from './skills.js'
import { WorkspaceService, type WorkspaceRawFile } from '../services/workspaceService.js'
@@ -152,6 +153,17 @@ export async function handleSessionsApi(
return await batchDeleteSessions(req)
}
// Special collection route: /api/sessions/live-status
if (sessionId === 'live-status') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return Response.json({ sessions: listLiveSessionActivity() })
}
// Special collection route: /api/sessions/recent-projects
if (sessionId === 'recent-projects' && req.method === 'GET') {
return await getRecentProjects(url)
@@ -1343,6 +1343,11 @@ export class ConversationService {
return this.sessions.has(sessionId)
}
/** Team workers report through their lead: their requests already count there. */
isTeamWorkerSession(sessionId: string): boolean {
return Boolean(this.sessions.get(sessionId)?.teamWorker)
}
getSessionWorkDir(sessionId: string): string {
const session = this.sessions.get(sessionId)
return session?.workDir || ''
+29
View File
@@ -479,6 +479,35 @@ export function getSessionChatActivityState(sessionId: string): SessionChatActiv
?? (legacyQueuedSessionChats.has(sessionId) ? 'running' : 'idle')
}
export type LiveSessionActivity = {
id: string
activityState: Extract<SessionChatActivityState, 'running' | 'waiting'>
}
/**
* Every session that is working or waiting on the user right now, in one
* answer. The phone's session list has to sort sessions it never opened a
* socket to, and asking each one would cost a request per row. Team workers
* are left out: their requests already surface on the lead.
*/
export function listLiveSessionActivity(): LiveSessionActivity[] {
const ids = new Set<string>([
...conversationService.getActiveSessions(),
...activeUserTurns.keys(),
...activeCliRuns,
...legacyQueuedSessionChats,
])
const live: LiveSessionActivity[] = []
for (const id of ids) {
if (conversationService.isTeamWorkerSession(id)) continue
const activityState = getSessionChatActivityState(id)
if (activityState === 'running' || activityState === 'waiting') {
live.push({ id, activityState })
}
}
return live
}
/** Compatibility fallback for the legacy REST enqueue endpoint. */
export function markSessionChatQueued(sessionId: string): void {
beginSessionChatActivity(sessionId)