mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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': "すべての設定",
|
||||
}
|
||||
|
||||
@@ -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': "전체 설정",
|
||||
}
|
||||
|
||||
@@ -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': "全部設定",
|
||||
}
|
||||
|
||||
@@ -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': "全部设置",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user