mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
177 lines
5.9 KiB
TypeScript
177 lines
5.9 KiB
TypeScript
import { useState } from 'react'
|
|
import { Pressable, Text, View } from 'react-native'
|
|
import { ChevronRight, Circle, CircleCheck, CircleDot, ListChecks } from 'lucide-react-native'
|
|
import {
|
|
diffNativeChatTaskLists,
|
|
NATIVE_CHAT_TASK_LIST_COPY,
|
|
nativeChatTaskChangeWords,
|
|
nativeChatTaskLabel,
|
|
type NativeChatTask,
|
|
type NativeChatTaskList as TaskList
|
|
} from '../../../src/shared/native-chat-task-list'
|
|
import { colors } from '../theme/mobile-theme'
|
|
import { useMobileNativeChatOpenedDetail } from './mobile-native-chat-opened-detail'
|
|
import { toolStyles as styles, toolType } from './mobile-native-chat-tool-styles'
|
|
|
|
const STATUS_WORD: Record<NativeChatTask['status'], string> = {
|
|
completed: NATIVE_CHAT_TASK_LIST_COPY.completed,
|
|
in_progress: NATIVE_CHAT_TASK_LIST_COPY.inProgress,
|
|
pending: NATIVE_CHAT_TASK_LIST_COPY.pending
|
|
}
|
|
|
|
/** Content plus occurrence is the only identity the providers give these entries. */
|
|
function occurrenceKeyed<T>(items: readonly T[], content: (item: T) => string): [T, string][] {
|
|
const seen = new Map<string, number>()
|
|
return items.map((item) => {
|
|
const signature = content(item)
|
|
const occurrence = seen.get(signature) ?? 0
|
|
seen.set(signature, occurrence + 1)
|
|
return [item, `${signature}:${occurrence}`]
|
|
})
|
|
}
|
|
|
|
function TaskRow({
|
|
task,
|
|
label,
|
|
fontScale
|
|
}: {
|
|
task: NativeChatTask
|
|
label?: string
|
|
fontScale: number
|
|
}): React.JSX.Element {
|
|
const Icon =
|
|
task.status === 'completed' ? CircleCheck : task.status === 'in_progress' ? CircleDot : Circle
|
|
const active = task.status === 'in_progress'
|
|
return (
|
|
<View
|
|
style={styles.taskRow}
|
|
accessibilityLabel={`${STATUS_WORD[task.status]}: ${label ?? nativeChatTaskLabel(task)}`}
|
|
>
|
|
<View style={[styles.taskIcon, { height: toolType(fontScale).row.lineHeight }]}>
|
|
<Icon
|
|
size={14 * fontScale}
|
|
color={active ? colors.textPrimary : colors.textMuted}
|
|
strokeWidth={2}
|
|
/>
|
|
</View>
|
|
<Text
|
|
style={[
|
|
styles.taskText,
|
|
toolType(fontScale).row,
|
|
active && styles.taskActive,
|
|
!label && task.status === 'completed' && styles.struck
|
|
]}
|
|
>
|
|
{label ?? nativeChatTaskLabel(task)}
|
|
</Text>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function Checklist({ list, fontScale }: { list: TaskList; fontScale: number }) {
|
|
return list.tasks.length === 0 ? (
|
|
<Text style={[styles.lineMeta, toolType(fontScale).meta]}>
|
|
{NATIVE_CHAT_TASK_LIST_COPY.empty}
|
|
</Text>
|
|
) : (
|
|
<View style={styles.taskList}>
|
|
{occurrenceKeyed(list.tasks, (task) => task.content).map(([task, key]) => (
|
|
<TaskRow key={key} task={task} fontScale={fontScale} />
|
|
))}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
/** The whole list behind a disclosure, under a list of what changed. */
|
|
function FullList({ list, fontScale }: { list: TaskList; fontScale: number }) {
|
|
const [open, setOpen] = useState(false)
|
|
return (
|
|
<View>
|
|
<Pressable
|
|
style={styles.taskDisclosure}
|
|
onPress={() => setOpen((value) => !value)}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ expanded: open }}
|
|
>
|
|
<View style={open ? styles.caretOpen : undefined}>
|
|
<ChevronRight size={14 * fontScale} color={colors.textMuted} strokeWidth={2} />
|
|
</View>
|
|
<Text style={[styles.lineMeta, toolType(fontScale).meta]}>
|
|
{NATIVE_CHAT_TASK_LIST_COPY.showAll}
|
|
</Text>
|
|
</Pressable>
|
|
{open ? <OpenedChecklist list={list} fontScale={fontScale} /> : null}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function OpenedChecklist({ list, fontScale }: { list: TaskList; fontScale: number }) {
|
|
const onLayout = useMobileNativeChatOpenedDetail()
|
|
return (
|
|
<View onLayout={onLayout}>
|
|
<Checklist list={list} fontScale={fontScale} />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
/** Desktop parity: `NativeChatTaskList`, inline. A plan update reads as what it
|
|
* changed against the previous list, or as the whole checklist when it is the first. */
|
|
export function MobileNativeChatTaskList({
|
|
list,
|
|
previous,
|
|
fontScale
|
|
}: {
|
|
list: TaskList
|
|
previous?: TaskList
|
|
fontScale: number
|
|
}): React.JSX.Element {
|
|
const completed = list.tasks.filter((task) => task.status === 'completed').length
|
|
const changes = previous ? diffNativeChatTaskLists(previous, list) : null
|
|
const type = toolType(fontScale)
|
|
return (
|
|
<View style={styles.taskCard}>
|
|
<View
|
|
style={styles.taskHeader}
|
|
accessibilityLabel={NATIVE_CHAT_TASK_LIST_COPY.progress
|
|
.replace('{{completed}}', String(completed))
|
|
.replace('{{total}}', String(list.tasks.length))}
|
|
>
|
|
<ListChecks size={15 * fontScale} color={colors.textMuted} strokeWidth={2} />
|
|
<Text style={[styles.lineMeta, styles.taskTitle, type.meta]}>
|
|
{NATIVE_CHAT_TASK_LIST_COPY.title}
|
|
</Text>
|
|
<Text style={[styles.lineMeta, type.meta]}>{`${completed}/${list.tasks.length}`}</Text>
|
|
</View>
|
|
{changes ? (
|
|
<>
|
|
{changes.length > 0 ? (
|
|
<View style={styles.taskList}>
|
|
{occurrenceKeyed(changes, (change) => `${change.kind}:${change.task.content}`).map(
|
|
([change, rowKey]) => {
|
|
const { key, task } = nativeChatTaskChangeWords(change)
|
|
return (
|
|
<TaskRow
|
|
key={rowKey}
|
|
task={change.task}
|
|
label={NATIVE_CHAT_TASK_LIST_COPY[key].replace('{{task}}', task)}
|
|
fontScale={fontScale}
|
|
/>
|
|
)
|
|
}
|
|
)}
|
|
</View>
|
|
) : (
|
|
<Text style={[styles.lineMeta, type.meta]}>{NATIVE_CHAT_TASK_LIST_COPY.unchanged}</Text>
|
|
)}
|
|
<FullList list={list} fontScale={fontScale} />
|
|
</>
|
|
) : (
|
|
<Checklist list={list} fontScale={fontScale} />
|
|
)}
|
|
{list.explanation ? (
|
|
<Text style={[styles.lineMeta, type.meta]}>{list.explanation}</Text>
|
|
) : null}
|
|
</View>
|
|
)
|
|
}
|