feat(mobile-voice): add swipeable prompt history pager in transcription panel
Swipe right-to-left on the transcription area to browse previous prompts sent in the current session. Includes haptic feedback on page snap, auto-snap to live page on record start, and layout-measured page width for correct alignment.
This commit is contained in:
parent
cd3a58a4c2
commit
e0894d7b63
2 changed files with 282 additions and 52 deletions
|
|
@ -5,6 +5,7 @@ import {
|
||||||
View,
|
View,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
FlatList,
|
||||||
Modal,
|
Modal,
|
||||||
Alert,
|
Alert,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
|
@ -38,7 +39,12 @@ import { fetch as expoFetch } from "expo/fetch"
|
||||||
import { buildPermissionCardModel } from "@/lib/pending-permissions"
|
import { buildPermissionCardModel } from "@/lib/pending-permissions"
|
||||||
import { unregisterRelayDevice } from "@/lib/relay-client"
|
import { unregisterRelayDevice } from "@/lib/relay-client"
|
||||||
import { useMdnsDiscovery } from "@/hooks/use-mdns-discovery"
|
import { useMdnsDiscovery } from "@/hooks/use-mdns-discovery"
|
||||||
import { useMonitoring, type MonitorJob, type PermissionDecision } from "@/hooks/use-monitoring"
|
import {
|
||||||
|
useMonitoring,
|
||||||
|
type MonitorJob,
|
||||||
|
type PermissionDecision,
|
||||||
|
type PromptHistoryEntry,
|
||||||
|
} from "@/hooks/use-monitoring"
|
||||||
import { DEFAULT_RELAY_URL, looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions"
|
import { DEFAULT_RELAY_URL, looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions"
|
||||||
import { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications"
|
import { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications"
|
||||||
|
|
||||||
|
|
@ -728,6 +734,8 @@ export default function DictationScreen() {
|
||||||
const scanLockRef = useRef(false)
|
const scanLockRef = useRef(false)
|
||||||
const pairProbeRunRef = useRef(0)
|
const pairProbeRunRef = useRef(0)
|
||||||
const whisperRestoredRef = useRef(false)
|
const whisperRestoredRef = useRef(false)
|
||||||
|
const promptPagerRef = useRef<FlatList<PromptHistoryEntry | "live">>(null)
|
||||||
|
const promptPagerPageRef = useRef(-1)
|
||||||
|
|
||||||
const closeDropdown = useCallback(() => {
|
const closeDropdown = useCallback(() => {
|
||||||
setDropdownMode("none")
|
setDropdownMode("none")
|
||||||
|
|
@ -766,13 +774,17 @@ export default function DictationScreen() {
|
||||||
activePermissionRequest,
|
activePermissionRequest,
|
||||||
devicePushToken,
|
devicePushToken,
|
||||||
latestAssistantContext,
|
latestAssistantContext,
|
||||||
|
latestPromptText,
|
||||||
latestAssistantResponse,
|
latestAssistantResponse,
|
||||||
monitorJob,
|
monitorJob,
|
||||||
monitorStatus,
|
monitorStatus,
|
||||||
pendingPermissionCount,
|
pendingPermissionCount,
|
||||||
|
promptHistory,
|
||||||
respondingPermissionID,
|
respondingPermissionID,
|
||||||
respondToPermission,
|
respondToPermission,
|
||||||
setDevicePushToken,
|
setDevicePushToken,
|
||||||
|
setLatestPromptText,
|
||||||
|
setPromptHistory,
|
||||||
setMonitorStatus,
|
setMonitorStatus,
|
||||||
} = useMonitoring({
|
} = useMonitoring({
|
||||||
completePlayer,
|
completePlayer,
|
||||||
|
|
@ -1766,6 +1778,8 @@ export default function DictationScreen() {
|
||||||
throw new Error(`Prompt request failed (${response.status})`)
|
throw new Error(`Prompt request failed (${response.status})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLatestPromptText(text)
|
||||||
|
|
||||||
const nextJob: MonitorJob = {
|
const nextJob: MonitorJob = {
|
||||||
id: `job-${Date.now()}`,
|
id: `job-${Date.now()}`,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
|
@ -1813,6 +1827,7 @@ export default function DictationScreen() {
|
||||||
isSending,
|
isSending,
|
||||||
serversRef,
|
serversRef,
|
||||||
setMonitorStatus,
|
setMonitorStatus,
|
||||||
|
setLatestPromptText,
|
||||||
sendOutProgress,
|
sendOutProgress,
|
||||||
sendPlayer,
|
sendPlayer,
|
||||||
transcribedText,
|
transcribedText,
|
||||||
|
|
@ -1828,6 +1843,14 @@ export default function DictationScreen() {
|
||||||
setDropdownMode("none")
|
setDropdownMode("none")
|
||||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {})
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {})
|
||||||
isHoldingRef.current = true
|
isHoldingRef.current = true
|
||||||
|
// Snap pager to live page (index 0) so user sees their transcription
|
||||||
|
if (promptPagerRef.current) {
|
||||||
|
try {
|
||||||
|
promptPagerRef.current.scrollToIndex({ index: 0, animated: true })
|
||||||
|
} catch {
|
||||||
|
// FlatList may not have items yet
|
||||||
|
}
|
||||||
|
}
|
||||||
void startRecording()
|
void startRecording()
|
||||||
}, [startRecording])
|
}, [startRecording])
|
||||||
|
|
||||||
|
|
@ -1910,6 +1933,32 @@ export default function DictationScreen() {
|
||||||
const isReplyingToActivePermission =
|
const isReplyingToActivePermission =
|
||||||
activePermissionRequest !== null && respondingPermissionID === activePermissionRequest.id
|
activePermissionRequest !== null && respondingPermissionID === activePermissionRequest.id
|
||||||
const displayedTranscript = isSending ? "" : transcribedText
|
const displayedTranscript = isSending ? "" : transcribedText
|
||||||
|
const [transcriptionPanelWidth, setTranscriptionPanelWidth] = useState(0)
|
||||||
|
const handleTranscriptionPanelLayout = useCallback((e: LayoutChangeEvent) => {
|
||||||
|
setTranscriptionPanelWidth(e.nativeEvent.layout.width)
|
||||||
|
}, [])
|
||||||
|
const pagerPageWidth = transcriptionPanelWidth || 1
|
||||||
|
|
||||||
|
// Prompt history pager: "live" at index 0 (leftmost), then history newest-first to the right.
|
||||||
|
// Swipe right-to-left to browse older prompts, swipe left-to-right to return to live.
|
||||||
|
const promptPagerData = useMemo<(PromptHistoryEntry | "live")[]>(
|
||||||
|
() => (promptHistory.length > 0 ? ["live" as const, ...[...promptHistory].reverse()] : []),
|
||||||
|
[promptHistory],
|
||||||
|
)
|
||||||
|
const promptPagerKeyExtractor = useCallback(
|
||||||
|
(item: PromptHistoryEntry | "live") => (item === "live" ? "live" : item.userMessageID),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const handlePromptPagerSnap = useCallback(
|
||||||
|
(e: { nativeEvent: { contentOffset: { x: number } } }) => {
|
||||||
|
const pageIndex = Math.round(e.nativeEvent.contentOffset.x / pagerPageWidth)
|
||||||
|
if (pageIndex !== promptPagerPageRef.current) {
|
||||||
|
promptPagerPageRef.current = pageIndex
|
||||||
|
void Haptics.selectionAsync().catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pagerPageWidth],
|
||||||
|
)
|
||||||
const isDropdownOpen = dropdownMode !== "none"
|
const isDropdownOpen = dropdownMode !== "none"
|
||||||
const effectiveDropdownMode = isDropdownOpen ? dropdownMode : dropdownRenderMode
|
const effectiveDropdownMode = isDropdownOpen ? dropdownMode : dropdownRenderMode
|
||||||
const isCreatingSession = sessionCreateMode !== null
|
const isCreatingSession = sessionCreateMode !== null
|
||||||
|
|
@ -2768,7 +2817,7 @@ export default function DictationScreen() {
|
||||||
body: "Control only listens while you hold the record button.",
|
body: "Control only listens while you hold the record button.",
|
||||||
primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone access..." : "Continue",
|
primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone access..." : "Continue",
|
||||||
primaryDisabled: microphonePermissionState === "pending",
|
primaryDisabled: microphonePermissionState === "pending",
|
||||||
secondaryLabel: "Continue without granting",
|
secondaryLabel: undefined,
|
||||||
visualTag: "MIC",
|
visualTag: "MIC",
|
||||||
visualSurfaceStyle: styles.onboardingVisualSurfaceMic,
|
visualSurfaceStyle: styles.onboardingVisualSurfaceMic,
|
||||||
visualOrbStyle: styles.onboardingVisualOrbMic,
|
visualOrbStyle: styles.onboardingVisualOrbMic,
|
||||||
|
|
@ -2779,7 +2828,7 @@ export default function DictationScreen() {
|
||||||
body: "Get alerts when your OpenCode run finishes, fails, or needs your attention.",
|
body: "Get alerts when your OpenCode run finishes, fails, or needs your attention.",
|
||||||
primaryLabel: notificationPermissionState === "pending" ? "Requesting notification access..." : "Continue",
|
primaryLabel: notificationPermissionState === "pending" ? "Requesting notification access..." : "Continue",
|
||||||
primaryDisabled: notificationPermissionState === "pending",
|
primaryDisabled: notificationPermissionState === "pending",
|
||||||
secondaryLabel: "Continue without granting",
|
secondaryLabel: undefined,
|
||||||
visualTag: "PUSH",
|
visualTag: "PUSH",
|
||||||
visualSurfaceStyle: styles.onboardingVisualSurfaceNotifications,
|
visualSurfaceStyle: styles.onboardingVisualSurfaceNotifications,
|
||||||
visualOrbStyle: styles.onboardingVisualOrbNotifications,
|
visualOrbStyle: styles.onboardingVisualOrbNotifications,
|
||||||
|
|
@ -2790,7 +2839,7 @@ export default function DictationScreen() {
|
||||||
body: "This lets Control discover your machine on the same network.",
|
body: "This lets Control discover your machine on the same network.",
|
||||||
primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network access..." : "Continue",
|
primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network access..." : "Continue",
|
||||||
primaryDisabled: localNetworkPermissionState === "pending",
|
primaryDisabled: localNetworkPermissionState === "pending",
|
||||||
secondaryLabel: "Continue without granting",
|
secondaryLabel: undefined,
|
||||||
visualTag: "LAN",
|
visualTag: "LAN",
|
||||||
visualSurfaceStyle: styles.onboardingVisualSurfaceNetwork,
|
visualSurfaceStyle: styles.onboardingVisualSurfaceNetwork,
|
||||||
visualOrbStyle: styles.onboardingVisualOrbNetwork,
|
visualOrbStyle: styles.onboardingVisualOrbNetwork,
|
||||||
|
|
@ -2918,19 +2967,21 @@ export default function DictationScreen() {
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
{onboardingSecondaryLabel ? (
|
||||||
onPress={() => {
|
<Pressable
|
||||||
if (clampedOnboardingStep < onboardingStepCount - 1) {
|
onPress={() => {
|
||||||
setOnboardingStep((step) => Math.min(step + 1, onboardingStepCount - 1))
|
if (clampedOnboardingStep < onboardingStepCount - 1) {
|
||||||
return
|
setOnboardingStep((step) => Math.min(step + 1, onboardingStepCount - 1))
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
completeOnboarding(false)
|
completeOnboarding(false)
|
||||||
}}
|
}}
|
||||||
style={({ pressed }) => [styles.onboardingSecondaryButton, pressed && styles.clearButtonPressed]}
|
style={({ pressed }) => [styles.onboardingSecondaryButton, pressed && styles.clearButtonPressed]}
|
||||||
>
|
>
|
||||||
<Text style={styles.onboardingSecondaryText}>{onboardingSecondaryLabel}</Text>
|
<Text style={styles.onboardingSecondaryText}>{onboardingSecondaryLabel}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|
@ -3335,7 +3386,7 @@ export default function DictationScreen() {
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.transcriptionPanel}>
|
<View style={styles.transcriptionPanel} onLayout={handleTranscriptionPanelLayout}>
|
||||||
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
|
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleOpenWhisperSettings}
|
onPress={handleOpenWhisperSettings}
|
||||||
|
|
@ -3364,20 +3415,66 @@ export default function DictationScreen() {
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ScrollView
|
{promptPagerData.length > 1 ? (
|
||||||
ref={scrollViewRef}
|
<FlatList
|
||||||
style={styles.transcriptionScroll}
|
ref={promptPagerRef}
|
||||||
contentContainerStyle={styles.transcriptionContent}
|
data={promptPagerData}
|
||||||
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
|
keyExtractor={promptPagerKeyExtractor}
|
||||||
>
|
horizontal
|
||||||
<Animated.View style={animatedTranscriptSendStyle}>
|
pagingEnabled
|
||||||
{displayedTranscript ? (
|
bounces={false}
|
||||||
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
|
showsHorizontalScrollIndicator={false}
|
||||||
) : isSending ? null : (
|
onMomentumScrollEnd={handlePromptPagerSnap}
|
||||||
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
|
initialScrollIndex={0}
|
||||||
)}
|
getItemLayout={(_data, index) => ({
|
||||||
</Animated.View>
|
length: pagerPageWidth,
|
||||||
</ScrollView>
|
offset: pagerPageWidth * index,
|
||||||
|
index,
|
||||||
|
})}
|
||||||
|
style={styles.transcriptionScroll}
|
||||||
|
renderItem={({ item }) =>
|
||||||
|
item === "live" ? (
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
|
style={{ width: pagerPageWidth }}
|
||||||
|
contentContainerStyle={styles.transcriptionContent}
|
||||||
|
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
|
||||||
|
>
|
||||||
|
<Animated.View style={animatedTranscriptSendStyle}>
|
||||||
|
{displayedTranscript ? (
|
||||||
|
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
|
||||||
|
) : isSending ? null : (
|
||||||
|
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
style={{ width: pagerPageWidth }}
|
||||||
|
contentContainerStyle={styles.transcriptionContent}
|
||||||
|
>
|
||||||
|
<Text style={styles.promptHistoryLabel}>Previous prompt</Text>
|
||||||
|
<Text style={styles.promptHistoryText}>{item.promptText}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
|
style={styles.transcriptionScroll}
|
||||||
|
contentContainerStyle={styles.transcriptionContent}
|
||||||
|
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
|
||||||
|
>
|
||||||
|
<Animated.View style={animatedTranscriptSendStyle}>
|
||||||
|
{displayedTranscript ? (
|
||||||
|
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
|
||||||
|
) : isSending ? null : (
|
||||||
|
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
|
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
|
||||||
|
|
@ -3451,7 +3548,7 @@ export default function DictationScreen() {
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.transcriptionPanel}>
|
<View style={styles.transcriptionPanel} onLayout={handleTranscriptionPanelLayout}>
|
||||||
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
|
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleOpenWhisperSettings}
|
onPress={handleOpenWhisperSettings}
|
||||||
|
|
@ -3480,20 +3577,59 @@ export default function DictationScreen() {
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ScrollView
|
{promptPagerData.length > 1 ? (
|
||||||
ref={scrollViewRef}
|
<FlatList
|
||||||
style={styles.transcriptionScroll}
|
ref={promptPagerRef}
|
||||||
contentContainerStyle={styles.transcriptionContent}
|
data={promptPagerData}
|
||||||
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
|
keyExtractor={promptPagerKeyExtractor}
|
||||||
>
|
horizontal
|
||||||
<Animated.View style={animatedTranscriptSendStyle}>
|
pagingEnabled
|
||||||
{displayedTranscript ? (
|
bounces={false}
|
||||||
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
|
showsHorizontalScrollIndicator={false}
|
||||||
) : isSending ? null : (
|
onMomentumScrollEnd={handlePromptPagerSnap}
|
||||||
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
|
initialScrollIndex={0}
|
||||||
)}
|
getItemLayout={(_data, index) => ({ length: pagerPageWidth, offset: pagerPageWidth * index, index })}
|
||||||
</Animated.View>
|
style={styles.transcriptionScroll}
|
||||||
</ScrollView>
|
renderItem={({ item }) =>
|
||||||
|
item === "live" ? (
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
|
style={{ width: pagerPageWidth }}
|
||||||
|
contentContainerStyle={styles.transcriptionContent}
|
||||||
|
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
|
||||||
|
>
|
||||||
|
<Animated.View style={animatedTranscriptSendStyle}>
|
||||||
|
{displayedTranscript ? (
|
||||||
|
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
|
||||||
|
) : isSending ? null : (
|
||||||
|
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<ScrollView style={{ width: pagerPageWidth }} contentContainerStyle={styles.transcriptionContent}>
|
||||||
|
<Text style={styles.promptHistoryLabel}>Previous prompt</Text>
|
||||||
|
<Text style={styles.promptHistoryText}>{item.promptText}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
|
style={styles.transcriptionScroll}
|
||||||
|
contentContainerStyle={styles.transcriptionContent}
|
||||||
|
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
|
||||||
|
>
|
||||||
|
<Animated.View style={animatedTranscriptSendStyle}>
|
||||||
|
{displayedTranscript ? (
|
||||||
|
<Text style={styles.transcriptionText}>{displayedTranscript}</Text>
|
||||||
|
) : isSending ? null : (
|
||||||
|
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
|
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
|
||||||
|
|
@ -4784,8 +4920,23 @@ const styles = StyleSheet.create({
|
||||||
right: 10,
|
right: 10,
|
||||||
zIndex: 4,
|
zIndex: 4,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
},
|
},
|
||||||
|
promptHistoryLabel: {
|
||||||
|
color: "#6B7A99",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "700",
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
promptHistoryText: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "500",
|
||||||
|
lineHeight: 34,
|
||||||
|
color: "#8B96AD",
|
||||||
|
},
|
||||||
modelErrorBadge: {
|
modelErrorBadge: {
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
marginLeft: 14,
|
marginLeft: 14,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@ export type MonitorJob = {
|
||||||
|
|
||||||
export type PermissionDecision = "once" | "always" | "reject"
|
export type PermissionDecision = "once" | "always" | "reject"
|
||||||
|
|
||||||
|
export type PromptHistoryEntry = {
|
||||||
|
promptText: string
|
||||||
|
userMessageID: string
|
||||||
|
}
|
||||||
|
|
||||||
type SessionRuntimeStatus = "idle" | "busy" | "retry"
|
type SessionRuntimeStatus = "idle" | "busy" | "retry"
|
||||||
|
|
||||||
type PermissionPromptState = "idle" | "pending" | "granted" | "denied"
|
type PermissionPromptState = "idle" | "pending" | "granted" | "denied"
|
||||||
|
|
@ -121,6 +126,8 @@ export function useMonitoring({
|
||||||
const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null)
|
const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null)
|
||||||
const [monitorStatus, setMonitorStatus] = useState("")
|
const [monitorStatus, setMonitorStatus] = useState("")
|
||||||
const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
|
const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
|
||||||
|
const [latestPromptText, setLatestPromptText] = useState("")
|
||||||
|
const [promptHistory, setPromptHistory] = useState<PromptHistoryEntry[]>([])
|
||||||
const [latestAssistantContext, setLatestAssistantContext] = useState<LatestAssistantContext | null>(null)
|
const [latestAssistantContext, setLatestAssistantContext] = useState<LatestAssistantContext | null>(null)
|
||||||
const [pendingPermissions, setPendingPermissions] = useState<PendingPermissionRequest[]>([])
|
const [pendingPermissions, setPendingPermissions] = useState<PendingPermissionRequest[]>([])
|
||||||
const [replyingPermissionID, setReplyingPermissionID] = useState<string | null>(null)
|
const [replyingPermissionID, setReplyingPermissionID] = useState<string | null>(null)
|
||||||
|
|
@ -250,10 +257,14 @@ export function useMonitoring({
|
||||||
|
|
||||||
const payload = (await response.json()) as unknown
|
const payload = (await response.json()) as unknown
|
||||||
const latest = findLatestAssistantCompletion(payload)
|
const latest = findLatestAssistantCompletion(payload)
|
||||||
|
const promptText = findLatestUserPrompt(payload)
|
||||||
|
const history = buildPromptHistory(payload)
|
||||||
|
|
||||||
if (latestAssistantRequestRef.current !== requestID) return
|
if (latestAssistantRequestRef.current !== requestID) return
|
||||||
if (activeSessionIdRef.current !== sessionID) return
|
if (activeSessionIdRef.current !== sessionID) return
|
||||||
setLatestAssistantResponse(latest.text)
|
setLatestAssistantResponse(latest.text)
|
||||||
|
setLatestPromptText(promptText)
|
||||||
|
setPromptHistory(history)
|
||||||
setLatestAssistantContext(latest.context)
|
setLatestAssistantContext(latest.context)
|
||||||
if (latest.text) {
|
if (latest.text) {
|
||||||
setAgentStateDismissed(false)
|
setAgentStateDismissed(false)
|
||||||
|
|
@ -262,6 +273,8 @@ export function useMonitoring({
|
||||||
if (latestAssistantRequestRef.current !== requestID) return
|
if (latestAssistantRequestRef.current !== requestID) return
|
||||||
if (activeSessionIdRef.current !== sessionID) return
|
if (activeSessionIdRef.current !== sessionID) return
|
||||||
setLatestAssistantResponse("")
|
setLatestAssistantResponse("")
|
||||||
|
setLatestPromptText("")
|
||||||
|
setPromptHistory([])
|
||||||
setLatestAssistantContext(null)
|
setLatestAssistantContext(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -446,6 +459,8 @@ export function useMonitoring({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLatestAssistantResponse("")
|
setLatestAssistantResponse("")
|
||||||
|
setLatestPromptText("")
|
||||||
|
setPromptHistory([])
|
||||||
setLatestAssistantContext(null)
|
setLatestAssistantContext(null)
|
||||||
setPendingPermissions([])
|
setPendingPermissions([])
|
||||||
setAgentStateDismissed(false)
|
setAgentStateDismissed(false)
|
||||||
|
|
@ -790,6 +805,10 @@ export function useMonitoring({
|
||||||
monitorJob,
|
monitorJob,
|
||||||
monitorStatus,
|
monitorStatus,
|
||||||
setMonitorStatus,
|
setMonitorStatus,
|
||||||
|
latestPromptText,
|
||||||
|
setLatestPromptText,
|
||||||
|
promptHistory,
|
||||||
|
setPromptHistory,
|
||||||
latestAssistantResponse,
|
latestAssistantResponse,
|
||||||
latestAssistantContext,
|
latestAssistantContext,
|
||||||
activePermissionRequest,
|
activePermissionRequest,
|
||||||
|
|
@ -839,12 +858,76 @@ function cleanSessionText(text: string): string {
|
||||||
return cleanTranscriptText(text).trimStart()
|
return cleanTranscriptText(text).trimStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractMessageText(parts: SessionMessagePart[]): string {
|
||||||
|
const textParts: string[] = []
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part || part.type !== "text" || typeof part.text !== "string") continue
|
||||||
|
|
||||||
|
const text = cleanSessionText(part.text)
|
||||||
|
if (text.length > 0) {
|
||||||
|
textParts.push(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return textParts.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
function maybeString(value: unknown): string | null {
|
function maybeString(value: unknown): string | null {
|
||||||
if (typeof value !== "string") return null
|
if (typeof value !== "string") return null
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
return trimmed.length > 0 ? trimmed : null
|
return trimmed.length > 0 ? trimmed : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPromptHistory(payload: unknown): PromptHistoryEntry[] {
|
||||||
|
if (!Array.isArray(payload)) return []
|
||||||
|
|
||||||
|
const entries: PromptHistoryEntry[] = []
|
||||||
|
|
||||||
|
for (const candidate of payload) {
|
||||||
|
const msg = candidate as SessionMessagePayload
|
||||||
|
if (!msg || typeof msg !== "object") continue
|
||||||
|
|
||||||
|
const info = msg.info as SessionMessageInfo
|
||||||
|
if (!info || typeof info !== "object") continue
|
||||||
|
if (info.role !== "user") continue
|
||||||
|
|
||||||
|
const id = (info as { id?: unknown }).id
|
||||||
|
if (typeof id !== "string") continue
|
||||||
|
|
||||||
|
const parts = Array.isArray(msg.parts) ? (msg.parts as SessionMessagePart[]) : []
|
||||||
|
const text = extractMessageText(parts)
|
||||||
|
if (text.length === 0) continue
|
||||||
|
|
||||||
|
entries.push({ promptText: text, userMessageID: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLatestUserPrompt(payload: unknown): string {
|
||||||
|
if (!Array.isArray(payload)) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = payload.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = payload[index] as SessionMessagePayload
|
||||||
|
if (!candidate || typeof candidate !== "object") continue
|
||||||
|
|
||||||
|
const info = candidate.info as SessionMessageInfo
|
||||||
|
if (!info || typeof info !== "object") continue
|
||||||
|
if (info.role !== "user") continue
|
||||||
|
|
||||||
|
const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
|
||||||
|
const text = extractMessageText(parts)
|
||||||
|
if (text.length > 0) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
function extractAssistantContext(info: SessionMessageInfo): LatestAssistantContext | null {
|
function extractAssistantContext(info: SessionMessageInfo): LatestAssistantContext | null {
|
||||||
const providerID = maybeString(info.providerID)
|
const providerID = maybeString(info.providerID)
|
||||||
const modelID = maybeString(info.modelID)
|
const modelID = maybeString(info.modelID)
|
||||||
|
|
@ -887,11 +970,7 @@ function findLatestAssistantCompletion(payload: unknown): LatestAssistantSnapsho
|
||||||
const context = extractAssistantContext(info)
|
const context = extractAssistantContext(info)
|
||||||
|
|
||||||
const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
|
const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
|
||||||
const text = parts
|
const text = extractMessageText(parts)
|
||||||
.filter((part) => part && part.type === "text" && typeof part.text === "string")
|
|
||||||
.map((part) => cleanSessionText(part.text as string))
|
|
||||||
.filter((part) => part.length > 0)
|
|
||||||
.join("\n\n")
|
|
||||||
|
|
||||||
if (text.length > 0 || context) {
|
if (text.length > 0 || context) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue