* test: E2E coverage for directory picker, all-sessions, variant picker Extend the Maestro suite for the features merged into main today: DirectoryBrowserSheet's server-folder picker, the directory-less all-sessions-across-projects list (+ the #46/#48 open-across-project regression), and VariantPicker's reasoning-effort chip. - tests/fixtures/mock-opencode-server.ts: GET /file (directory-scoped via the x-opencode-directory header) with a small fake tree, GET /project for the "Server Projects" section, POST /session honoring the directory header, GET /session/:id (needed to open a session from the all-sessions list), GET /provider variants for VariantPicker, and an optional --seed-sessions mode that pre-populates two sessions across two directories. --fail-auth mode is untouched. - .maestro/flows/directory-picker.yaml, all-sessions.yaml, variant-picker.yaml: three new flows, run in the same emulator session as the existing activation flows. - Additive testIDs on DirectoryBrowserSheet, the "Browse Folders" row, session list rows, the variant chip, and VariantPicker rows. - .github/workflows/activation-e2e.yml: two more mock server instances (4098 seeded, 4099 fresh) and three more maestro test steps. Verified: tsc --noEmit clean, all 108 existing unit tests pass, every new mock endpoint curled against its real shape read from the app code, YAML validated. No Android emulator available locally to run the Maestro flows themselves. * test(mock): enforce per-directory session scoping so #46/#48 coverage can fail Review finding (HIGH): GET /session/:id and /session/:id/message ignored x-opencode-directory, so all-sessions.yaml could not fail if the directory threading fix regressed. The mock now mirrors the real server's per-directory workspace scoping: - GET /session/:id and GET /session/:id/message 404 unless the request's x-opencode-directory (or DEFAULT_DIRECTORY when absent) matches the stored session's directory. - GET /session without ?roots=true is scoped to the request's directory; loadSessions()'s directory-less roots=true call still returns everything. - Document the port-4099 shared-state coupling between directory-picker and variant-picker flows, and why all-sessions.yaml now has teeth (flow comment). Curl-verified: correct header 200, wrong/no header 404, scoped vs roots listing, create-then-open paths for all three flows, --fail-auth untouched. tsc clean, 108/108 unit tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E * fix(e2e): widen connect-handshake wait past client's own 30s timeout Run 29546383612 (7cbd3a6, first real emulator execution of these flows) failed on activation-positive.yaml: "Assert that id: connection-status-dot is visible" timed out after the flow's 20s extendedWaitUntil, right after tapOn connect-submit-button. The mock server itself is fast (verified locally: health + project/current + path respond in ~30ms total), so this isn't a mock fidelity gap. But Quick Connect's testConnection()/addConnection() path chains up to 3 fetches (health, then project.current + path.get in parallel), and each individual fetch is capped by src/lib/sdk.ts REQUEST_TIMEOUT_MS = 30_000 — strictly longer than the 20s the flow was willing to wait. A first-attempt emulator-to-host (10.0.2.2) connection that's merely slow to establish, rather than outright failing, would blow past the test's wait before the app's own client-side timeout even fires. Bump the connect -> connection-status-dot / "Connection Failed" waits from 20000 to 40000 across all 5 flows that share this pattern (activation-positive, activation-negative-401, all-sessions, directory-picker, variant-picker) so the wait is never shorter than the code path it's gating on. Assertions are unchanged — still requires the real dot / real error text, just with a timeout that isn't racing the client. Verified locally: typecheck clean, all 108 unit tests pass, YAML parses, mock server confirmed fast under direct curl. Emulator behavior itself (whether 40s consistently clears it) is unverified until the next CI run. * fix(e2e): use adb reverse + 127.0.0.1 instead of 10.0.2.2; capture logcat/maestro debug Root cause of the activation-e2e failure (connect step timed out, ~0 requests reaching the mock): the 10.0.2.2 host alias is unreliable under the headless emulator-runner — the app's http://10.0.2.2:4096/global/health never completed, so connection-status-dot never rendered. - run-e2e-flows.sh: single script (fixes cd-per-line fragility) that adb-reverses each mock port (4096-4099) into the emulator's localhost, runs every flow with --debug-output, and dumps logcat on exit. - All flows now connect to 127.0.0.1:<port> (the adb reverse target). - Upload maestro-debug (UI hierarchy on failure) + logcat as artifacts so future failures are diagnosable instead of blind. * fix(e2e): connect via 127.0.0.1:PORT in IP field, stop typing into port input Root cause of every activation-e2e connect failure (proven by the app's own logcat diagnostic: '[diag] probe start http://127.0.0.1:40966 ... server unreachable'): the port field defaults to useState("4096"), and the flow's eraseText + inputText "4096" raced the controlled number-pad input, leaving "40966" — nothing listens there, so connect always failed. This was never a 10.0.2.2 / adb reverse issue. Fix: buildUrl already extracts host:port from the IP field, so enter 127.0.0.1:<port> there and remove the flaky port-field steps entirely. pastedPort overrides the default port state, so each flow's port is deterministic (4096 positive / 4097 negative / 4098 all-sessions / 4099 directory+variant). --------- Co-authored-by: engineer <engineer@gray-knight-m1.local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1017 lines
35 KiB
TypeScript
1017 lines
35 KiB
TypeScript
import { useEffect, useRef, useState, useCallback, useMemo } from "react"
|
|
import {
|
|
View,
|
|
Text,
|
|
FlatList,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
StyleSheet,
|
|
useColorScheme,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
ActivityIndicator,
|
|
Alert,
|
|
} from "react-native"
|
|
import { useLocalSearchParams, Stack, useRouter } from "expo-router"
|
|
import { Ionicons } from "@expo/vector-icons"
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
|
import * as ImagePicker from "expo-image-picker"
|
|
import * as ImageManipulator from "expo-image-manipulator"
|
|
import * as Clipboard from "expo-clipboard"
|
|
import type BottomSheet from "@gorhom/bottom-sheet"
|
|
import {
|
|
MessageBubble,
|
|
PermissionPrompt,
|
|
QuestionPrompt,
|
|
StatusIndicator,
|
|
SlashPopover,
|
|
ModelPicker,
|
|
VariantPicker,
|
|
ImageAttachments,
|
|
SessionInfo,
|
|
type SlashCommand,
|
|
type Attachment,
|
|
} from "../../src/components/chat"
|
|
import { useSessions } from "../../src/stores/sessions"
|
|
import { useEvents, refreshPending } from "../../src/stores/events"
|
|
import { useConnections } from "../../src/stores/connections"
|
|
import { useAuth } from "../../src/stores/auth"
|
|
import { useCatalog } from "../../src/stores/catalog"
|
|
import { useSpeech } from "../../src/lib/speech"
|
|
|
|
// --- Builtin slash commands ---
|
|
const BUILTIN_COMMANDS: SlashCommand[] = [
|
|
{
|
|
trigger: "new",
|
|
title: "New Session",
|
|
description: "Start a new session",
|
|
icon: "add-circle-outline",
|
|
type: "builtin",
|
|
},
|
|
{
|
|
trigger: "model",
|
|
title: "Switch Model",
|
|
description: "Choose a different model",
|
|
icon: "hardware-chip-outline",
|
|
type: "builtin",
|
|
},
|
|
{
|
|
trigger: "agent",
|
|
title: "Switch Agent",
|
|
description: "Cycle to next agent",
|
|
icon: "person-outline",
|
|
type: "builtin",
|
|
},
|
|
]
|
|
|
|
function getShortDir(dir?: string): string | null {
|
|
if (!dir) return null
|
|
const parts = dir.split("/").filter(Boolean)
|
|
return parts[parts.length - 1] || null
|
|
}
|
|
|
|
export default function SessionScreen() {
|
|
const { id, directory } = useLocalSearchParams<{ id: string; directory?: string }>()
|
|
const router = useRouter()
|
|
const colorScheme = useColorScheme()
|
|
const isDark = colorScheme === "dark"
|
|
const insets = useSafeAreaInsets()
|
|
|
|
const flatListRef = useRef<FlatList>(null)
|
|
const modelSheetRef = useRef<BottomSheet>(null)
|
|
const variantSheetRef = useRef<BottomSheet>(null)
|
|
const [input, setInput] = useState("")
|
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
|
const [showInfo, setShowInfo] = useState(false)
|
|
|
|
const {
|
|
currentSession,
|
|
messages,
|
|
parts,
|
|
isLoading,
|
|
loadingMore,
|
|
hasMore,
|
|
selectSession,
|
|
sendMessage,
|
|
abortSession,
|
|
loadOlderMessages,
|
|
revertToMessage,
|
|
unrevertSession,
|
|
} = useSessions()
|
|
|
|
// Derive sending state for this specific session
|
|
const isSending = useSessions((s) => !!(currentSession && s.sending[currentSession.id]))
|
|
|
|
const { authenticateForMessage } = useAuth()
|
|
const { client, clientForDirectory } = useConnections()
|
|
|
|
// Use directory-aware client for sessions that belong to a project other than the active one
|
|
const sessionClient = useMemo(
|
|
() => (currentSession?.directory ? (clientForDirectory(currentSession.directory) ?? client) : client),
|
|
[currentSession?.directory, clientForDirectory, client],
|
|
)
|
|
|
|
// Catalog
|
|
const catalog = useCatalog()
|
|
const agents = Array.isArray(catalog.agents) ? catalog.agents : []
|
|
const serverCommands = Array.isArray(catalog.commands) ? catalog.commands : []
|
|
const providers = Array.isArray(catalog.providers) ? catalog.providers : []
|
|
const agent = catalog.agent || ""
|
|
const model = catalog.model
|
|
const setModel = catalog.setModel
|
|
const variant = catalog.variant
|
|
const setVariant = catalog.setVariant
|
|
const cycleAgent = catalog.cycleAgent
|
|
|
|
// Permission & question state
|
|
const sessionID = currentSession?.id
|
|
const permissions = useEvents((s) => (sessionID ? s.permissions[sessionID] : undefined)) || []
|
|
const questions = useEvents((s) => (sessionID ? s.questions[sessionID] : undefined)) || []
|
|
|
|
const shortDir = getShortDir(currentSession?.directory)
|
|
const [showScrollButton, setShowScrollButton] = useState(false)
|
|
|
|
// SSE reconnect banner
|
|
const reconnectAttempts = useEvents((s) => s.reconnectAttempts)
|
|
const [showConnectedFlash, setShowConnectedFlash] = useState(false)
|
|
const prevReconnecting = useRef(false)
|
|
|
|
// Voice input — transcript appends to the text input on completion
|
|
const speech = useSpeech(
|
|
useCallback((text: string) => {
|
|
setInput((prev) => (prev ? prev + " " + text : text))
|
|
}, []),
|
|
)
|
|
|
|
// Slash command state
|
|
const slashActive = input.startsWith("/") && !input.includes(" ")
|
|
const slashQuery = slashActive ? input.slice(1) : ""
|
|
|
|
const allCommands = useMemo<SlashCommand[]>(() => {
|
|
const custom: SlashCommand[] = serverCommands.map((cmd) => ({
|
|
trigger: cmd.name,
|
|
title: cmd.name,
|
|
description: cmd.description,
|
|
icon: "code-slash-outline",
|
|
type: "custom",
|
|
}))
|
|
return [...custom, ...BUILTIN_COMMANDS]
|
|
}, [serverCommands])
|
|
|
|
// While a revert is pending, the reverted message and everything after it
|
|
// still exist server-side (cleanup only runs on the next prompt/unrevert)
|
|
// — hide them client-side so editing feels immediate. Message IDs are
|
|
// lexicographically sortable, same comparison the TUI uses. Optimistic
|
|
// "temp-" IDs (assigned client-side before the server responds, see
|
|
// sendMessage) aren't part of that sort order — always keep them so a
|
|
// message sent concurrently with a revert isn't hidden.
|
|
const revertMessageID = currentSession?.revert?.messageID
|
|
|
|
// Inverted FlatList: data is reversed (newest first) so newest renders at bottom
|
|
const messageData = useMemo(
|
|
() =>
|
|
(messages || [])
|
|
.filter((msg) => !revertMessageID || msg.id.startsWith("temp-") || msg.id < revertMessageID)
|
|
.map((msg) => ({
|
|
message: msg,
|
|
parts: (parts && parts[msg.id]) || [],
|
|
}))
|
|
.reverse(),
|
|
[messages, parts, revertMessageID],
|
|
)
|
|
|
|
// Tracks the latest composer text without pulling `input` into
|
|
// handleMessageLongPress's deps — kept as a plain ref assignment (not
|
|
// state) so the callback below stays referentially stable across
|
|
// keystrokes for MessageBubble's custom memo comparator.
|
|
const inputRef = useRef(input)
|
|
inputRef.current = input
|
|
|
|
const applyRevertResult = useCallback((result: Awaited<ReturnType<typeof revertToMessage>>) => {
|
|
if (!result.ok) {
|
|
if (result.reason === "unsupported") {
|
|
Alert.alert(
|
|
"Not supported",
|
|
"Editing sent messages needs a newer opencode server. Please update the server and try again.",
|
|
)
|
|
} else if (result.reason === "auth") {
|
|
Alert.alert("Authentication failed", "Your server credentials were rejected. Check your connection and try again.")
|
|
} else {
|
|
Alert.alert("Edit failed", "Could not revert to this message. Please try again.")
|
|
}
|
|
return
|
|
}
|
|
setInput(result.text)
|
|
// Restore attachments in the same shape the composer's own picker
|
|
// functions (pickFromLibrary/pickFromCamera/pasteFromClipboard) use.
|
|
setAttachments(
|
|
result.files
|
|
.filter((f): f is typeof f & { url: string; mime: string } => !!f.url && !!f.mime)
|
|
.map((f) => ({ uri: f.url, mime: f.mime, filename: f.filename })),
|
|
)
|
|
}, [])
|
|
|
|
// Stable across renders (reads fresh state via getState() rather than
|
|
// closing over props) so MessageBubble's custom memo comparator can bail
|
|
// safely without risking a stale handler.
|
|
const handleMessageLongPress = useCallback((messageID: string) => {
|
|
Alert.alert("Message actions", undefined, [
|
|
{ text: "Cancel", style: "cancel" },
|
|
{
|
|
text: "Edit message",
|
|
onPress: () => {
|
|
const doRevert = async () => {
|
|
const result = await useSessions.getState().revertToMessage(messageID)
|
|
applyRevertResult(result)
|
|
}
|
|
// Editing overwrites the composer — don't silently clobber an
|
|
// in-progress unsent draft.
|
|
if (inputRef.current.trim()) {
|
|
Alert.alert(
|
|
"Replace draft?",
|
|
"You have an unsent message in the composer. Editing this message will replace it.",
|
|
[
|
|
{ text: "Cancel", style: "cancel" },
|
|
{ text: "Replace", style: "destructive", onPress: doRevert },
|
|
],
|
|
{ cancelable: false },
|
|
)
|
|
return
|
|
}
|
|
doRevert()
|
|
},
|
|
},
|
|
])
|
|
}, [applyRevertResult])
|
|
|
|
const scrollToBottom = useCallback((animated = true) => {
|
|
flatListRef.current?.scrollToOffset({ offset: 0, animated })
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!id) return
|
|
selectSession(id, directory).then(() => {
|
|
// Re-fetch pending permissions/questions from the server to recover from
|
|
// missed SSE events or failed optimistic removals
|
|
const connState = useConnections.getState()
|
|
const c = directory ? (connState.clientForDirectory(directory) ?? connState.client) : connState.client
|
|
if (c) refreshPending(c, id)
|
|
})
|
|
}, [id, directory])
|
|
|
|
// Sync model chip from latest assistant message
|
|
useEffect(() => {
|
|
if (!messages || messages.length === 0) return
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const msg = messages[i]
|
|
if (msg.role === "assistant" && msg.providerID && msg.modelID) {
|
|
setModel({ providerID: msg.providerID, modelID: msg.modelID })
|
|
return
|
|
}
|
|
if (msg.role === "user" && msg.model) {
|
|
setModel(msg.model)
|
|
return
|
|
}
|
|
}
|
|
}, [currentSession?.id, messages?.length])
|
|
|
|
// Slash command handler
|
|
const handleSlashSelect = useCallback(
|
|
(cmd: SlashCommand) => {
|
|
if (cmd.type === "builtin") {
|
|
switch (cmd.trigger) {
|
|
case "new":
|
|
router.back()
|
|
return
|
|
case "model":
|
|
setInput("")
|
|
modelSheetRef.current?.expand()
|
|
return
|
|
case "agent":
|
|
setInput("")
|
|
cycleAgent()
|
|
return
|
|
}
|
|
}
|
|
setInput(`/${cmd.trigger} `)
|
|
},
|
|
[router, cycleAgent],
|
|
)
|
|
|
|
// --- Image picking ---
|
|
|
|
// Convert any image (including HEIC/HEIF from iOS) to guaranteed JPEG bytes
|
|
const MAX_DIMENSION = 1568 // Anthropic recommended max
|
|
async function toJpeg(uri: string, width: number, height: number): Promise<Attachment> {
|
|
const actions: ImageManipulator.Action[] = []
|
|
if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
|
|
const scale = MAX_DIMENSION / Math.max(width, height)
|
|
actions.push({ resize: { width: Math.round(width * scale), height: Math.round(height * scale) } })
|
|
}
|
|
const result = await ImageManipulator.manipulateAsync(uri, actions, {
|
|
format: ImageManipulator.SaveFormat.JPEG,
|
|
compress: 0.8,
|
|
base64: true,
|
|
})
|
|
return {
|
|
uri: result.uri,
|
|
mime: "image/jpeg",
|
|
filename: "image.jpg",
|
|
width: result.width,
|
|
height: result.height,
|
|
base64: result.base64 || undefined,
|
|
}
|
|
}
|
|
|
|
const pickFromLibrary = useCallback(async () => {
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ["images"],
|
|
allowsMultipleSelection: true,
|
|
quality: 1, // full quality - we compress in manipulator
|
|
})
|
|
if (result.canceled) return
|
|
const items = await Promise.all(result.assets.map((a) => toJpeg(a.uri, a.width, a.height)))
|
|
setAttachments((prev) => [...prev, ...items])
|
|
}, [])
|
|
|
|
const pickFromCamera = useCallback(async () => {
|
|
const perm = await ImagePicker.requestCameraPermissionsAsync()
|
|
if (!perm.granted) {
|
|
Alert.alert("Permission needed", "Camera access is required to take photos.")
|
|
return
|
|
}
|
|
const result = await ImagePicker.launchCameraAsync({ quality: 1 })
|
|
if (result.canceled) return
|
|
const a = result.assets[0]
|
|
const item = await toJpeg(a.uri, a.width, a.height)
|
|
setAttachments((prev) => [...prev, item])
|
|
}, [])
|
|
|
|
const pasteFromClipboard = useCallback(async () => {
|
|
// Try image first
|
|
const hasImage = await Clipboard.hasImageAsync()
|
|
if (hasImage) {
|
|
const img = await Clipboard.getImageAsync({ format: "png" })
|
|
if (img?.data) {
|
|
const uri = img.data.startsWith("data:") ? img.data : `data:image/png;base64,${img.data}`
|
|
setAttachments((prev) => [
|
|
...prev,
|
|
{
|
|
uri,
|
|
mime: "image/png",
|
|
filename: "clipboard.png",
|
|
width: img.size.width,
|
|
height: img.size.height,
|
|
},
|
|
])
|
|
return
|
|
}
|
|
}
|
|
// Fall back to text
|
|
const hasText = await Clipboard.hasStringAsync()
|
|
if (hasText) {
|
|
const text = await Clipboard.getStringAsync()
|
|
if (text) {
|
|
setInput((prev) => prev + text)
|
|
return
|
|
}
|
|
}
|
|
Alert.alert("Empty clipboard", "Clipboard does not contain text or an image.")
|
|
}, [])
|
|
|
|
const removeAttachment = useCallback((index: number) => {
|
|
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
|
}, [])
|
|
|
|
// --- Send ---
|
|
const handleSend = async () => {
|
|
if (!input.trim() && attachments.length === 0) return
|
|
const authenticated = await authenticateForMessage()
|
|
if (!authenticated) {
|
|
Alert.alert("Authentication required", "Biometric authentication is required to send. Please try again.")
|
|
return
|
|
}
|
|
|
|
const text = input.trim()
|
|
const files = [...attachments]
|
|
setInput("")
|
|
setAttachments([])
|
|
|
|
// Server slash commands (no attachments for commands)
|
|
if (text.startsWith("/") && files.length === 0) {
|
|
const [cmdName, ...args] = text.split(" ")
|
|
const name = cmdName.slice(1)
|
|
const match = serverCommands.find((c) => c.name === name)
|
|
if (match && sessionClient && currentSession) {
|
|
sessionClient.session
|
|
.command(currentSession.id, {
|
|
command: name,
|
|
arguments: args.join(" "),
|
|
agent,
|
|
model: model ? `${model.providerID}/${model.modelID}` : undefined,
|
|
})
|
|
.catch((err) => console.error("Command failed:", err))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Messages are queued server-side when the session is busy.
|
|
// No need to abort - just send and it will be processed after current response.
|
|
try {
|
|
await sendMessage(text, model || undefined, agent || undefined, files, variant || undefined)
|
|
} catch (err) {
|
|
console.error("Send failed:", err)
|
|
// Restore the user's text and attachments so their input isn't lost.
|
|
setInput((prev) => (prev ? prev : text))
|
|
setAttachments((prev) => (prev.length ? prev : files))
|
|
Alert.alert("Message not sent", "Could not send your message. Check your connection and try again.")
|
|
}
|
|
}
|
|
|
|
// In inverted mode, offset 0 = bottom. Show scroll button when scrolled away from bottom.
|
|
const handleScroll = useCallback((event: any) => {
|
|
const { contentOffset } = event.nativeEvent
|
|
setShowScrollButton(contentOffset.y > 200)
|
|
}, [])
|
|
|
|
// Debounce: onEndReached can fire multiple times during a single scroll gesture
|
|
const loadingTriggered = useRef(false)
|
|
const handleLoadMore = useCallback(() => {
|
|
if (hasMore && !loadingMore && !loadingTriggered.current) {
|
|
loadingTriggered.current = true
|
|
loadOlderMessages()
|
|
}
|
|
}, [hasMore, loadingMore, loadOlderMessages])
|
|
|
|
// Reset trigger when loading finishes
|
|
useEffect(() => {
|
|
if (!loadingMore) loadingTriggered.current = false
|
|
}, [loadingMore])
|
|
|
|
// Detect reconnecting → stable transition for the "Connected ✓" flash.
|
|
// reconnectAttempts and lastDisconnectAt reset in the same set() call, so we
|
|
// can't use lastDisconnectAt alone; a useRef tracks the prior reconnecting state.
|
|
useEffect(() => {
|
|
const isReconnecting = reconnectAttempts > 0
|
|
if (prevReconnecting.current && !isReconnecting) {
|
|
setShowConnectedFlash(true)
|
|
const t = setTimeout(() => setShowConnectedFlash(false), 2000)
|
|
return () => clearTimeout(t)
|
|
}
|
|
prevReconnecting.current = isReconnecting
|
|
}, [reconnectAttempts])
|
|
|
|
const handlePermissionReply = async (requestID: string, reply: "once" | "always" | "reject") => {
|
|
if (!sessionClient || !sessionID) return
|
|
// Snapshot for rollback
|
|
const snapshot = useEvents.getState().permissions[sessionID] || []
|
|
// Optimistically remove from UI
|
|
useEvents.setState((state) => ({
|
|
permissions: {
|
|
...state.permissions,
|
|
[sessionID]: snapshot.filter((p) => p.id !== requestID),
|
|
},
|
|
}))
|
|
try {
|
|
await sessionClient.permission.reply(requestID, reply)
|
|
} catch (err) {
|
|
console.error("Permission reply failed:", err)
|
|
// Restore the prompt so the user can retry
|
|
useEvents.setState((state) => ({
|
|
permissions: { ...state.permissions, [sessionID]: snapshot },
|
|
}))
|
|
Alert.alert("Reply Failed", "Could not send your response. Please try again.")
|
|
}
|
|
}
|
|
|
|
const handleQuestionReply = async (requestID: string, answers: string[][]) => {
|
|
if (!sessionClient || !sessionID) return
|
|
const snapshot = useEvents.getState().questions[sessionID] || []
|
|
useEvents.setState((state) => ({
|
|
questions: {
|
|
...state.questions,
|
|
[sessionID]: snapshot.filter((q) => q.id !== requestID),
|
|
},
|
|
}))
|
|
try {
|
|
await sessionClient.question.reply(requestID, answers)
|
|
} catch (err) {
|
|
console.error("Question reply failed:", err)
|
|
useEvents.setState((state) => ({
|
|
questions: { ...state.questions, [sessionID]: snapshot },
|
|
}))
|
|
Alert.alert("Reply Failed", "Could not send your response. Please try again.")
|
|
}
|
|
}
|
|
|
|
const handleQuestionReject = async (requestID: string) => {
|
|
if (!sessionClient || !sessionID) return
|
|
const snapshot = useEvents.getState().questions[sessionID] || []
|
|
useEvents.setState((state) => ({
|
|
questions: {
|
|
...state.questions,
|
|
[sessionID]: snapshot.filter((q) => q.id !== requestID),
|
|
},
|
|
}))
|
|
try {
|
|
await sessionClient.question.reject(requestID)
|
|
} catch (err) {
|
|
console.error("Question reject failed:", err)
|
|
useEvents.setState((state) => ({
|
|
questions: { ...state.questions, [sessionID]: snapshot },
|
|
}))
|
|
Alert.alert("Reject Failed", "Could not send your response. Please try again.")
|
|
}
|
|
}
|
|
|
|
const handleModelSelect = useCallback(
|
|
(providerID: string, modelID: string) => {
|
|
setModel({ providerID, modelID })
|
|
},
|
|
[setModel],
|
|
)
|
|
|
|
// Current agent display
|
|
const currentAgent = agents.find((a) => a.name === agent)
|
|
const agentColor = currentAgent?.color || "#8b5cf6"
|
|
const modelLabel = model?.modelID ? model.modelID.split("/").pop() || model.modelID : "default"
|
|
|
|
// Variants for current model (for reasoning effort picker)
|
|
const currentModelVariants = useMemo(() => {
|
|
if (!model) return undefined
|
|
const provider = providers.find((p) => p.id === model.providerID)
|
|
const found = provider?.models.find((m) => m.id === model.modelID)
|
|
return found?.variants
|
|
}, [model, providers])
|
|
|
|
return (
|
|
<>
|
|
<Stack.Screen
|
|
options={{
|
|
title: currentSession?.title || "Session",
|
|
headerRight: () => (
|
|
<View style={s.headerRight}>
|
|
{shortDir && (
|
|
<View style={[s.dirBadge, isDark && s.dirBadgeDark]}>
|
|
<Ionicons name="folder-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
|
<Text style={[s.dirText, isDark && s.dirTextDark]}>{shortDir}</Text>
|
|
</View>
|
|
)}
|
|
<TouchableOpacity onPress={() => setShowInfo((v) => !v)} hitSlop={8}>
|
|
<Ionicons
|
|
name={showInfo ? "stats-chart" : "stats-chart-outline"}
|
|
size={20}
|
|
color={showInfo ? "#3b82f6" : isDark ? "#888888" : "#666666"}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
),
|
|
}}
|
|
/>
|
|
|
|
<KeyboardAvoidingView
|
|
style={[s.container, isDark && s.containerDark]}
|
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
|
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
|
|
>
|
|
{/* Session info pulldown */}
|
|
<SessionInfo
|
|
session={currentSession}
|
|
messages={messages || []}
|
|
providers={providers}
|
|
visible={showInfo}
|
|
isDark={isDark}
|
|
hasMore={hasMore}
|
|
loadingAll={loadingMore}
|
|
onLoadAll={() => {
|
|
if (hasMore && !loadingMore) loadOlderMessages()
|
|
}}
|
|
onScrollToTop={() => {
|
|
flatListRef.current?.scrollToEnd({ animated: true })
|
|
}}
|
|
onClose={() => setShowInfo(false)}
|
|
/>
|
|
|
|
{/* SSE reconnect/connected banner */}
|
|
{reconnectAttempts > 0 && (
|
|
<View style={[s.banner, s.bannerReconnecting]}>
|
|
<Text style={s.bannerText}>Reconnecting\u2026 (attempt {reconnectAttempts})</Text>
|
|
</View>
|
|
)}
|
|
{showConnectedFlash && reconnectAttempts === 0 && (
|
|
<View style={[s.banner, s.bannerConnected]}>
|
|
<Text style={s.bannerText}>Connected ✓</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Pending revert (from "Edit message") — offer a way back before it's
|
|
cleaned up by the next prompt. */}
|
|
{revertMessageID && (
|
|
<View style={[s.banner, s.bannerRevert]}>
|
|
<Text style={s.bannerText}>Message reverted — resend to confirm</Text>
|
|
<TouchableOpacity onPress={() => unrevertSession()} hitSlop={8}>
|
|
<Text style={s.bannerAction}>Undo</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<View style={s.loading}>
|
|
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
|
</View>
|
|
) : (
|
|
<View style={s.listWrap}>
|
|
<FlatList
|
|
ref={flatListRef}
|
|
data={messageData}
|
|
inverted
|
|
keyExtractor={(item) => item.message.id}
|
|
renderItem={({ item }) => (
|
|
<MessageBubble
|
|
message={item.message}
|
|
parts={item.parts}
|
|
isDark={isDark}
|
|
onLongPress={handleMessageLongPress}
|
|
/>
|
|
)}
|
|
contentContainerStyle={s.messageList}
|
|
onScroll={handleScroll}
|
|
scrollEventThrottle={100}
|
|
onEndReached={handleLoadMore}
|
|
onEndReachedThreshold={0.5}
|
|
// Prevent jump when older messages are prepended
|
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
|
ListFooterComponent={
|
|
loadingMore ? (
|
|
<View style={s.loadingMore}>
|
|
<ActivityIndicator size="small" color={isDark ? "#888888" : "#666666"} />
|
|
<Text style={[s.loadingMoreText, isDark && s.metaDark]}>Loading older messages...</Text>
|
|
</View>
|
|
) : null
|
|
}
|
|
/>
|
|
{/* Empty state rendered OUTSIDE the inverted list to avoid the
|
|
inverted transform mirroring its text/icon (see #ui-mirror). */}
|
|
{messageData.length === 0 && (
|
|
<View style={s.emptyOverlay} pointerEvents="none">
|
|
<Ionicons name="chatbubble-outline" size={48} color={isDark ? "#444444" : "#cccccc"} />
|
|
<Text style={[s.emptyText, isDark && s.metaDark]}>Start a conversation</Text>
|
|
<Text style={[s.emptyHint, isDark && s.metaDark]}>Type / for commands</Text>
|
|
</View>
|
|
)}
|
|
{showScrollButton && (
|
|
<TouchableOpacity style={[s.scrollBtn, isDark && s.scrollBtnDark]} onPress={() => scrollToBottom(true)}>
|
|
<Ionicons name="chevron-down" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{/* Status */}
|
|
{currentSession && <StatusIndicator sessionID={currentSession.id} isDark={isDark} />}
|
|
|
|
{/* Permissions */}
|
|
{permissions.map((perm) => (
|
|
<PermissionPrompt
|
|
key={perm.id}
|
|
permission={perm}
|
|
isDark={isDark}
|
|
onReply={(reply) => handlePermissionReply(perm.id, reply)}
|
|
/>
|
|
))}
|
|
|
|
{/* Questions */}
|
|
{questions.map((q) => (
|
|
<QuestionPrompt
|
|
key={q.id}
|
|
request={q}
|
|
isDark={isDark}
|
|
onReply={(answers) => handleQuestionReply(q.id, answers)}
|
|
onReject={() => handleQuestionReject(q.id)}
|
|
/>
|
|
))}
|
|
|
|
{/* Slash popover */}
|
|
{slashActive && (
|
|
<SlashPopover query={slashQuery} commands={allCommands} isDark={isDark} onSelect={handleSlashSelect} />
|
|
)}
|
|
|
|
{/* Agent/model toolbar */}
|
|
<View style={[s.toolbar, isDark && s.toolbarDark]}>
|
|
<TouchableOpacity
|
|
style={[s.agentChip, { borderColor: agentColor }]}
|
|
onPress={() => cycleAgent()}
|
|
onLongPress={() => cycleAgent(-1)}
|
|
>
|
|
<View style={[s.agentDot, { backgroundColor: agentColor }]} />
|
|
<Text style={[s.agentLabel, isDark && s.textWhite]}>{agent || "build"}</Text>
|
|
<Ionicons name="swap-horizontal-outline" size={12} color={isDark ? "#888888" : "#666666"} />
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
style={[s.modelChip, isDark && s.modelChipDark]}
|
|
onPress={() => modelSheetRef.current?.expand()}
|
|
>
|
|
<Ionicons name="hardware-chip-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
|
<Text style={[s.modelLabel, isDark && s.metaDark]} numberOfLines={1}>
|
|
{modelLabel}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
|
|
{currentModelVariants && Object.keys(currentModelVariants).length > 0 && (
|
|
<TouchableOpacity
|
|
style={[s.variantChip, isDark && s.variantChipDark, variant && s.variantChipActive]}
|
|
onPress={() => variantSheetRef.current?.expand()}
|
|
testID="variant-chip"
|
|
>
|
|
<Ionicons name="flash-outline" size={14} color={variant ? "#8b5cf6" : isDark ? "#888888" : "#666666"} />
|
|
<Text style={[s.variantLabel, isDark && s.metaDark, variant && s.variantLabelActive]} numberOfLines={1}>
|
|
{variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : "Auto"}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
{/* Attachment preview */}
|
|
<ImageAttachments attachments={attachments} isDark={isDark} onRemove={removeAttachment} />
|
|
|
|
{/* Input */}
|
|
<View
|
|
style={[s.inputContainer, isDark && s.inputContainerDark, { paddingBottom: Math.max(12, insets.bottom) }]}
|
|
>
|
|
<View style={s.inputRow}>
|
|
{/* Attach button */}
|
|
<TouchableOpacity style={s.attachBtn} onPress={pickFromLibrary} onLongPress={pickFromCamera}>
|
|
<Ionicons name="add-circle-outline" size={26} color={isDark ? "#888888" : "#666666"} />
|
|
</TouchableOpacity>
|
|
|
|
{/* Clipboard paste button */}
|
|
<TouchableOpacity style={s.attachBtn} onPress={pasteFromClipboard}>
|
|
<Ionicons name="clipboard-outline" size={22} color={isDark ? "#888888" : "#666666"} />
|
|
</TouchableOpacity>
|
|
|
|
<TextInput
|
|
style={[s.input, isDark && s.inputDark, speech.listening && s.inputListening]}
|
|
placeholder={speech.listening ? "Listening..." : isSending ? "Send a follow-up..." : "Type a message..."}
|
|
placeholderTextColor={speech.listening ? "#ef4444" : isDark ? "#666666" : "#999999"}
|
|
value={speech.listening ? speech.transcript : input}
|
|
onChangeText={speech.listening ? undefined : setInput}
|
|
editable={!speech.listening}
|
|
multiline
|
|
maxLength={10000}
|
|
testID="chat-message-input"
|
|
/>
|
|
{/* Stop button: only when busy and no input */}
|
|
{isSending && !input.trim() && attachments.length === 0 && !speech.listening && (
|
|
<TouchableOpacity style={s.stopBtn} onPress={abortSession}>
|
|
<Ionicons name="stop" size={20} color="#ffffff" />
|
|
</TouchableOpacity>
|
|
)}
|
|
{/* Mic button: when no input, not sending, and not listening */}
|
|
{!isSending && !input.trim() && attachments.length === 0 && !speech.listening && (
|
|
<TouchableOpacity style={s.micBtn} onPress={speech.start}>
|
|
<Ionicons name="mic" size={22} color={isDark ? "#888888" : "#666666"} />
|
|
</TouchableOpacity>
|
|
)}
|
|
{/* Listening indicator: tap to stop */}
|
|
{speech.listening && (
|
|
<TouchableOpacity style={s.micBtnActive} onPress={speech.stop}>
|
|
<Ionicons name="mic" size={22} color="#ffffff" />
|
|
</TouchableOpacity>
|
|
)}
|
|
{/* Send button: when there's input */}
|
|
{!speech.listening && (input.trim() || attachments.length > 0) && (
|
|
<TouchableOpacity style={s.sendBtn} onPress={handleSend} testID="chat-send-button">
|
|
<Ionicons name="send" size={20} color="#ffffff" />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
|
|
{/* Model picker bottom sheet */}
|
|
<ModelPicker
|
|
sheetRef={modelSheetRef}
|
|
providers={providers}
|
|
selected={model}
|
|
isDark={isDark}
|
|
onSelect={handleModelSelect}
|
|
/>
|
|
|
|
{/* Reasoning effort (variant) picker bottom sheet */}
|
|
<VariantPicker
|
|
sheetRef={variantSheetRef}
|
|
variants={currentModelVariants}
|
|
selected={variant}
|
|
isDark={isDark}
|
|
onSelect={setVariant}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const s = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: "#ffffff" },
|
|
containerDark: { backgroundColor: "#0a0a0a" },
|
|
loading: { flex: 1, justifyContent: "center", alignItems: "center" },
|
|
listWrap: { flex: 1, position: "relative" },
|
|
|
|
// Messages
|
|
messageList: { padding: 16, paddingBottom: 8 },
|
|
|
|
// Scroll button
|
|
scrollBtn: {
|
|
position: "absolute",
|
|
bottom: 16,
|
|
right: 16,
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 22,
|
|
backgroundColor: "#ffffff",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.2,
|
|
shadowRadius: 4,
|
|
elevation: 4,
|
|
},
|
|
scrollBtnDark: { backgroundColor: "#2a2a2a" },
|
|
|
|
// Loading more (appears at top in inverted list = ListFooterComponent)
|
|
loadingMore: {
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
paddingVertical: 16,
|
|
},
|
|
loadingMoreText: { fontSize: 13, color: "#999999" },
|
|
|
|
// Empty state overlay — sits on top of the (empty) inverted list, untransformed,
|
|
// so its text/icon render upright and un-mirrored on Android.
|
|
emptyOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
paddingVertical: 64,
|
|
},
|
|
|
|
// Empty
|
|
empty: { flex: 1, justifyContent: "center", alignItems: "center", paddingVertical: 64 },
|
|
emptyText: { fontSize: 16, color: "#999999", marginTop: 12 },
|
|
emptyHint: { fontSize: 13, color: "#bbbbbb", marginTop: 4 },
|
|
metaDark: { color: "#666666" },
|
|
textWhite: { color: "#ffffff" },
|
|
|
|
// Toolbar
|
|
toolbar: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
borderTopWidth: 1,
|
|
borderTopColor: "#e5e5e5",
|
|
backgroundColor: "#ffffff",
|
|
},
|
|
toolbarDark: { borderTopColor: "#1a1a1a", backgroundColor: "#0a0a0a" },
|
|
agentChip: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 5,
|
|
borderWidth: 1,
|
|
borderRadius: 12,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 4,
|
|
},
|
|
agentDot: { width: 8, height: 8, borderRadius: 4 },
|
|
agentLabel: { fontSize: 12, fontWeight: "600", color: "#0a0a0a" },
|
|
modelChip: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
backgroundColor: "#f5f5f5",
|
|
borderRadius: 12,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 4,
|
|
},
|
|
modelChipDark: { backgroundColor: "#1a1a1a" },
|
|
modelLabel: { fontSize: 12, color: "#666666", maxWidth: 160 },
|
|
|
|
// Variant (reasoning effort) chip
|
|
variantChip: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
backgroundColor: "#f5f5f5",
|
|
borderRadius: 12,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 4,
|
|
},
|
|
variantChipDark: { backgroundColor: "#1a1a1a" },
|
|
variantChipActive: { backgroundColor: "#f5f3ff" },
|
|
variantLabel: { fontSize: 12, color: "#666666" },
|
|
variantLabelActive: { color: "#8b5cf6" },
|
|
|
|
// Input
|
|
inputContainer: {
|
|
padding: 12,
|
|
borderTopWidth: 1,
|
|
borderTopColor: "#e5e5e5",
|
|
backgroundColor: "#ffffff",
|
|
},
|
|
inputContainerDark: { borderTopColor: "#1a1a1a", backgroundColor: "#0a0a0a" },
|
|
inputRow: {
|
|
flexDirection: "row",
|
|
alignItems: "flex-end",
|
|
},
|
|
attachBtn: {
|
|
width: 36,
|
|
height: 40,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
input: {
|
|
flex: 1,
|
|
backgroundColor: "#f5f5f5",
|
|
borderRadius: 20,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 10,
|
|
fontSize: 16,
|
|
maxHeight: 120,
|
|
color: "#0a0a0a",
|
|
},
|
|
inputDark: { backgroundColor: "#1a1a1a", color: "#ffffff" },
|
|
inputListening: { borderWidth: 1, borderColor: "#ef4444" },
|
|
sendBtn: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
backgroundColor: "#0a0a0a",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
marginLeft: 8,
|
|
},
|
|
sendBtnDisabled: { backgroundColor: "#cccccc" },
|
|
micBtn: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
marginLeft: 8,
|
|
},
|
|
micBtnActive: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
backgroundColor: "#ef4444",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
marginLeft: 8,
|
|
},
|
|
stopBtn: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
backgroundColor: "#ef4444",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
marginLeft: 8,
|
|
},
|
|
|
|
// Header
|
|
headerRight: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
dirBadge: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
backgroundColor: "#f5f5f5",
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 4,
|
|
borderRadius: 6,
|
|
},
|
|
dirBadgeDark: { backgroundColor: "#1a1a1a" },
|
|
dirText: { fontSize: 12, color: "#666666", fontWeight: "500" },
|
|
dirTextDark: { color: "#888888" },
|
|
|
|
// SSE reconnect/connected banner
|
|
banner: {
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 6,
|
|
alignItems: "center",
|
|
},
|
|
bannerReconnecting: { backgroundColor: "#92400e" },
|
|
bannerConnected: { backgroundColor: "#065f46" },
|
|
bannerText: { color: "#ffffff", fontSize: 13, fontWeight: "500" },
|
|
|
|
// Pending revert (edit message) banner
|
|
bannerRevert: {
|
|
backgroundColor: "#1e3a8a",
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
},
|
|
bannerAction: { color: "#93c5fd", fontSize: 13, fontWeight: "700" },
|
|
})
|