Adds next-sentence suggestions while typing, weekly input insights and a personal phrase memory to the desktop app, and fixes custom instructions so they process the text instead of inserting the instruction's own wording. Local model requests are now bounded and individually cancellable. Bumps the product version to 1.5.0 (Android/iOS build 1050000), refreshes the landing and web download links, and records the new INPUT feature rows and the open verification gaps in the infrastructure map.
555 lines
21 KiB
TypeScript
555 lines
21 KiB
TypeScript
// src/renderer/components/input-insights/InputConsentPanel.tsx
|
|
// 입력 인텔리전스 — 동의 · 정책 · 실시간 진단.
|
|
//
|
|
// 상세 통계(요약/키보드/마우스/앱/문구)는 지식 베이스 화면의 "입력 인사이트" 탭에 있다.
|
|
// 이 패널은 "무엇을 수집하고, 언제 멈추고, 어떻게 지우는가" 만 다룬다.
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import {
|
|
Box,
|
|
Button,
|
|
Divider,
|
|
FormControlLabel,
|
|
MenuItem,
|
|
Select,
|
|
Switch,
|
|
TextField,
|
|
Typography
|
|
} from '@mui/material'
|
|
import { Trash2 } from 'lucide-react'
|
|
import { d3roPalette, d3roRadius, d3roTypo, typoSx } from '@d3ro/ui/theme'
|
|
import { useI18n } from '@d3ro/i18n'
|
|
import type { LLMModel } from '@d3ro/core/types'
|
|
import { useInputInsights } from '../../hooks/useInputInsights'
|
|
|
|
const OVERLINE_SX = { color: d3roPalette.text.label, letterSpacing: '1.5px' } as const
|
|
|
|
const HINT_SX = {
|
|
color: d3roPalette.text.secondary,
|
|
fontSize: d3roTypo.small.size,
|
|
lineHeight: d3roTypo.small.line
|
|
} as const
|
|
|
|
export function InputConsentPanel(): React.ReactElement {
|
|
const { t } = useI18n()
|
|
const { telemetry, suggestion, receipt, receiptUnavailable, refresh } = useInputInsights(7)
|
|
|
|
const [excludedApps, setExcludedApps] = useState('')
|
|
const [triggerDelayMs, setTriggerDelayMs] = useState('')
|
|
const [minPrefixChars, setMinPrefixChars] = useState('')
|
|
const [requestTimeoutMs, setRequestTimeoutMs] = useState('')
|
|
const [models, setModels] = useState<LLMModel[]>([])
|
|
const [feedback, setFeedback] = useState<{ message: string; tone: 'success' | 'error' } | null>(null)
|
|
|
|
const enabled = telemetry?.enabled ?? false
|
|
const paused = telemetry?.paused ?? false
|
|
const suggestionEnabled = suggestion?.enabled ?? false
|
|
|
|
useEffect(() => {
|
|
if (!telemetry) return
|
|
setExcludedApps(telemetry.excludedApps.join(', '))
|
|
}, [telemetry])
|
|
|
|
// 설정 파일에 굳은 실제 값을 그대로 보여준다 (하드코딩 기본값을 표시하면
|
|
// 사용자가 보는 값과 동작이 어긋난다 — 실측: 트리거가 3008ms 로 저장돼 있었다).
|
|
useEffect(() => {
|
|
if (!suggestion) return
|
|
setTriggerDelayMs(String(suggestion.triggerDelayMs))
|
|
setMinPrefixChars(String(suggestion.minPrefixChars))
|
|
setRequestTimeoutMs(String(suggestion.requestTimeoutMs))
|
|
}, [suggestion])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
void window.electronAPI.llm.getModels().then((result) => {
|
|
if (!cancelled && result.success) setModels(result.data)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
const flash = useCallback((message: string, tone: 'success' | 'error' = 'success'): void => {
|
|
setFeedback({ message, tone })
|
|
setTimeout(() => setFeedback(null), 2500)
|
|
}, [])
|
|
|
|
const handleConsent = useCallback(
|
|
async (next: boolean): Promise<void> => {
|
|
await window.electronAPI.inputTelemetry.setEnabled({ enabled: next })
|
|
await refresh()
|
|
},
|
|
[refresh]
|
|
)
|
|
|
|
const handlePause = useCallback(
|
|
async (next: boolean): Promise<void> => {
|
|
await window.electronAPI.inputTelemetry.setPaused({ paused: next })
|
|
await refresh()
|
|
},
|
|
[refresh]
|
|
)
|
|
|
|
const handleLearn = useCallback(
|
|
async (next: boolean): Promise<void> => {
|
|
await window.electronAPI.suggestion.setConfig({ learnTypedText: next })
|
|
await refresh()
|
|
},
|
|
[refresh]
|
|
)
|
|
|
|
const handleSuggestionToggle = useCallback(
|
|
async (next: boolean): Promise<void> => {
|
|
await window.electronAPI.suggestion.setConfig({ enabled: next })
|
|
await refresh()
|
|
},
|
|
[refresh]
|
|
)
|
|
|
|
const handleOverlayInteractive = useCallback(
|
|
async (next: boolean): Promise<void> => {
|
|
await window.electronAPI.suggestion.setConfig({ overlayInteractive: next })
|
|
await refresh()
|
|
},
|
|
[refresh]
|
|
)
|
|
|
|
const handleModel = useCallback(
|
|
async (modelId: string): Promise<void> => {
|
|
await window.electronAPI.suggestion.setConfig({ modelId: modelId || null })
|
|
await refresh()
|
|
},
|
|
[refresh]
|
|
)
|
|
|
|
const handleExcludedAppsBlur = useCallback(async (): Promise<void> => {
|
|
const apps = excludedApps
|
|
.split(',')
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean)
|
|
const result = await window.electronAPI.suggestion.setConfig({ excludedApps: apps })
|
|
if (result.success) {
|
|
await refresh()
|
|
flash(t('input.feedback.excludedSaved'))
|
|
} else {
|
|
flash(t('input.feedback.excludedFailed'), 'error')
|
|
}
|
|
}, [excludedApps, refresh, t, flash])
|
|
|
|
const handleAddRecommendedApp = useCallback(
|
|
async (appName: string): Promise<void> => {
|
|
const candidates = excludedApps
|
|
.split(',')
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean)
|
|
.concat(appName)
|
|
const nextApps = candidates.filter(
|
|
(entry, index) => candidates.findIndex((candidate) => candidate.toLowerCase() === entry.toLowerCase()) === index
|
|
)
|
|
const result = await window.electronAPI.suggestion.setConfig({ excludedApps: nextApps })
|
|
if (result.success) {
|
|
setExcludedApps(nextApps.join(', '))
|
|
await refresh()
|
|
flash(t('input.feedback.recommendationSaved', { app: appName }))
|
|
} else {
|
|
flash(t('input.feedback.recommendationFailed', { app: appName }), 'error')
|
|
}
|
|
},
|
|
[excludedApps, flash, refresh, t]
|
|
)
|
|
|
|
const handleClearAll = useCallback(async (): Promise<void> => {
|
|
const result = await window.electronAPI.inputTelemetry.clearAll()
|
|
if (result.success) {
|
|
await refresh()
|
|
flash(t('input.feedback.cleared'))
|
|
} else {
|
|
flash(t('input.feedback.clearFailed'), 'error')
|
|
}
|
|
}, [refresh, t, flash])
|
|
|
|
const snapshot = telemetry?.lastSnapshot ?? null
|
|
const exclusionRecommendation = telemetry?.exclusionRecommendation ?? null
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
|
{/* ── 동의 ─────────────────────────────────────── */}
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
|
<Typography variant="overline" sx={OVERLINE_SX}>
|
|
{t('input.consent.title')}
|
|
</Typography>
|
|
<Typography sx={HINT_SX}>{t('input.consent.description')}</Typography>
|
|
|
|
<Box
|
|
sx={{
|
|
p: 1.25,
|
|
borderRadius: d3roRadius.inner,
|
|
border: `1px solid ${d3roPalette.border.subtle}`,
|
|
bgcolor: d3roPalette.bg.inset,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 0.5
|
|
}}
|
|
>
|
|
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>
|
|
{t('input.privacy.title')}
|
|
</Typography>
|
|
<Typography sx={HINT_SX}>{t('input.privacy.localOnly')}</Typography>
|
|
<Typography sx={HINT_SX}>{t('input.privacy.rawKeys')}</Typography>
|
|
{receipt ? (
|
|
<Box component="dl" sx={{ m: 0, display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', columnGap: 1, rowGap: 0.25 }}>
|
|
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.activityRetention')}</Typography>
|
|
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{t('input.privacy.days', { days: receipt.retention.activityDays })}</Typography>
|
|
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.typingSamplesRetention')}</Typography>
|
|
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{t('input.privacy.days', { days: receipt.retention.typingSamplesDays })}</Typography>
|
|
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.suggestionRetention')}</Typography>
|
|
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{t('input.privacy.days', { days: receipt.retention.suggestionDays })}</Typography>
|
|
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.learnedRetention')}</Typography>
|
|
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{t('input.privacy.untilDeleted')}</Typography>
|
|
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.activityBuckets')}</Typography>
|
|
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{receipt.counts.activityBuckets.toLocaleString()}</Typography>
|
|
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.typingSamples')}</Typography>
|
|
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{receipt.counts.typingSamples.toLocaleString()}</Typography>
|
|
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.personalPhrases')}</Typography>
|
|
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{receipt.counts.personalPhrases.toLocaleString()}</Typography>
|
|
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.suggestions')}</Typography>
|
|
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{receipt.counts.suggestions.toLocaleString()}</Typography>
|
|
</Box>
|
|
) : receiptUnavailable ? (
|
|
<Typography sx={HINT_SX} role="status" aria-live="polite">{t('input.privacy.unavailable')}</Typography>
|
|
) : null}
|
|
</Box>
|
|
|
|
<FormControlLabel
|
|
control={
|
|
<Switch size="small" checked={enabled} onChange={(e) => void handleConsent(e.target.checked)} />
|
|
}
|
|
label={
|
|
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
|
|
{t('input.consent.collect')}
|
|
</Typography>
|
|
}
|
|
/>
|
|
<Typography sx={{ ...HINT_SX, pl: 6 }}>
|
|
{telemetry?.running ? t('input.consent.running') : t('input.consent.stopped')}
|
|
</Typography>
|
|
|
|
{/* 실시간 진단 — "왜 제안이 안 뜨는지" 를 사용자가 직접 볼 수 있게 한다. */}
|
|
<Box
|
|
sx={{
|
|
p: 1.25,
|
|
borderRadius: d3roRadius.inner,
|
|
border: `1px solid ${d3roPalette.border.subtle}`,
|
|
bgcolor: d3roPalette.bg.inset,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 0.25
|
|
}}
|
|
>
|
|
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>
|
|
{t('input.diagnostics.title')}
|
|
</Typography>
|
|
<Typography sx={HINT_SX}>
|
|
{snapshot
|
|
? t('input.diagnostics.app', { app: snapshot.appName ?? t('input.diagnostics.unknownApp') })
|
|
: t('input.diagnostics.noSnapshot')}
|
|
</Typography>
|
|
<Typography
|
|
sx={{
|
|
...HINT_SX,
|
|
color:
|
|
snapshot?.editable && !snapshot.isPassword
|
|
? d3roPalette.status.success
|
|
: d3roPalette.status.warning
|
|
}}
|
|
>
|
|
{snapshot?.isPassword
|
|
? t('input.diagnostics.password')
|
|
: snapshot?.editable
|
|
? t('input.diagnostics.readable', {
|
|
source: snapshot.textSource,
|
|
length: snapshot.textLength
|
|
})
|
|
: t('input.diagnostics.notReadable')}
|
|
</Typography>
|
|
{snapshot?.composing ? (
|
|
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
|
|
{t('input.diagnostics.composing')}
|
|
</Typography>
|
|
) : null}
|
|
{snapshot?.caretFallback && snapshot.editable ? (
|
|
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
|
|
{t('input.diagnostics.caretFallback')}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
|
|
{exclusionRecommendation ? (
|
|
<Box
|
|
sx={{
|
|
p: 1.25,
|
|
borderRadius: d3roRadius.inner,
|
|
border: `1px solid ${d3roPalette.status.warning}`,
|
|
bgcolor: d3roPalette.bg.inset,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'flex-start',
|
|
gap: 0.75
|
|
}}
|
|
>
|
|
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.secondary }} aria-live="polite">
|
|
{t('input.exclusion.recommendation', {
|
|
app: exclusionRecommendation.appName,
|
|
samples: exclusionRecommendation.samples,
|
|
reason:
|
|
exclusionRecommendation.reason === 'repeated-empty'
|
|
? t('input.exclusion.reason.repeated-empty')
|
|
: t('input.exclusion.reason.repeated-unreadable')
|
|
})}
|
|
</Typography>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
onClick={() => void handleAddRecommendedApp(exclusionRecommendation.appName)}
|
|
aria-label={t('input.exclusion.addButton', { app: exclusionRecommendation.appName })}
|
|
sx={{ fontSize: d3roTypo.label.size }}
|
|
>
|
|
{t('input.exclusion.addButton', { app: exclusionRecommendation.appName })}
|
|
</Button>
|
|
</Box>
|
|
) : null}
|
|
|
|
<FormControlLabel
|
|
disabled={!enabled}
|
|
control={<Switch size="small" checked={paused} onChange={(e) => void handlePause(e.target.checked)} />}
|
|
label={
|
|
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
|
|
{t('input.consent.pause')}
|
|
</Typography>
|
|
}
|
|
/>
|
|
|
|
<FormControlLabel
|
|
disabled={!enabled}
|
|
control={
|
|
<Switch
|
|
size="small"
|
|
checked={telemetry?.learnTypedText ?? false}
|
|
onChange={(e) => void handleLearn(e.target.checked)}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
|
|
{t('input.consent.learnText')}
|
|
</Typography>
|
|
}
|
|
/>
|
|
<Typography sx={{ ...HINT_SX, pl: 6 }}>{t('input.consent.learnTextHint')}</Typography>
|
|
|
|
<TextField
|
|
size="small"
|
|
fullWidth
|
|
disabled={!enabled}
|
|
label={t('input.consent.excludedApps')}
|
|
placeholder={t('input.consent.excludedAppsPlaceholder')}
|
|
value={excludedApps}
|
|
onChange={(e) => setExcludedApps(e.target.value)}
|
|
onBlur={() => void handleExcludedAppsBlur()}
|
|
helperText={t('input.consent.excludedAppsHint')}
|
|
InputProps={{ sx: { fontSize: d3roTypo.compact.size } }}
|
|
/>
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
color="error"
|
|
startIcon={<Trash2 size={14} />}
|
|
onClick={() => void handleClearAll()}
|
|
sx={{ fontSize: d3roTypo.label.size }}
|
|
>
|
|
{t('input.consent.clearAll')}
|
|
</Button>
|
|
{feedback ? (
|
|
<Typography
|
|
sx={{ ...HINT_SX, color: feedback.tone === 'success' ? d3roPalette.status.success : d3roPalette.status.danger }}
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
{feedback.message}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
|
|
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
|
|
{t('input.insights.statsMovedHint')}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
|
|
|
{/* ── 제안 정책 ─────────────────────────────────── */}
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
|
<Typography variant="overline" sx={OVERLINE_SX}>
|
|
{t('input.suggestion.title')}
|
|
</Typography>
|
|
<Typography sx={HINT_SX}>{t('input.suggestion.description')}</Typography>
|
|
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
size="small"
|
|
checked={suggestionEnabled}
|
|
onChange={(e) => void handleSuggestionToggle(e.target.checked)}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
|
|
{t('input.suggestion.enabled')}
|
|
</Typography>
|
|
}
|
|
/>
|
|
<Typography sx={HINT_SX}>
|
|
{suggestion?.modelAvailable ? t('input.suggestion.modelReady') : t('input.suggestion.modelMissing')}
|
|
</Typography>
|
|
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, alignItems: 'center' }}>
|
|
<TextField
|
|
size="small"
|
|
type="number"
|
|
disabled={!suggestionEnabled}
|
|
label={t('input.suggestion.delay')}
|
|
value={triggerDelayMs}
|
|
onChange={(e) => setTriggerDelayMs(e.target.value)}
|
|
onBlur={() =>
|
|
void window.electronAPI.suggestion
|
|
.setConfig({ triggerDelayMs: Number(triggerDelayMs) })
|
|
.then(() => refresh())
|
|
}
|
|
sx={{ width: 150 }}
|
|
/>
|
|
<TextField
|
|
size="small"
|
|
type="number"
|
|
disabled={!suggestionEnabled}
|
|
label={t('input.suggestion.minPrefix')}
|
|
value={minPrefixChars}
|
|
onChange={(e) => setMinPrefixChars(e.target.value)}
|
|
onBlur={() =>
|
|
void window.electronAPI.suggestion
|
|
.setConfig({ minPrefixChars: Number(minPrefixChars) })
|
|
.then(() => refresh())
|
|
}
|
|
sx={{ width: 150 }}
|
|
/>
|
|
<TextField
|
|
size="small"
|
|
type="number"
|
|
disabled={!suggestionEnabled}
|
|
label={t('input.suggestion.timeout')}
|
|
value={requestTimeoutMs}
|
|
onChange={(e) => setRequestTimeoutMs(e.target.value)}
|
|
onBlur={() =>
|
|
void window.electronAPI.suggestion
|
|
.setConfig({ requestTimeoutMs: Number(requestTimeoutMs) })
|
|
.then(() => refresh())
|
|
}
|
|
sx={{ width: 150 }}
|
|
/>
|
|
<Select
|
|
size="small"
|
|
disabled={!suggestionEnabled}
|
|
value={suggestion?.modelId ?? ''}
|
|
onChange={(e) => void handleModel(String(e.target.value))}
|
|
displayEmpty
|
|
sx={{ minWidth: 200, fontSize: d3roTypo.compact.size }}
|
|
>
|
|
<MenuItem value="">{t('input.suggestion.modelDefault')}</MenuItem>
|
|
{models.map((model) => (
|
|
<MenuItem key={model.name} value={model.name}>
|
|
{model.name}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</Box>
|
|
|
|
<FormControlLabel
|
|
disabled={!suggestionEnabled}
|
|
control={
|
|
<Switch
|
|
size="small"
|
|
checked={suggestion?.overlayInteractive ?? true}
|
|
onChange={(e) => void handleOverlayInteractive(e.target.checked)}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
|
|
{t('input.suggestion.overlayInteractive')}
|
|
</Typography>
|
|
}
|
|
/>
|
|
|
|
{/* 상태 표시 — 스위치로 두면 설정처럼 보여서 "왜 못 켜지?" 가 된다. */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>
|
|
{t('input.suggestion.onScreenLabel')}
|
|
</Typography>
|
|
<Box
|
|
sx={{
|
|
px: 0.75,
|
|
py: 0.25,
|
|
borderRadius: d3roRadius.xs,
|
|
bgcolor: suggestion?.visible ? d3roPalette.accent.dim : d3roPalette.bg.inset,
|
|
border: `1px solid ${suggestion?.visible ? d3roPalette.accent.main : d3roPalette.border.subtle}`
|
|
}}
|
|
>
|
|
<Typography
|
|
sx={{
|
|
...typoSx('micro'),
|
|
color: suggestion?.visible ? d3roPalette.accent.main : d3roPalette.text.inactive
|
|
}}
|
|
>
|
|
{suggestion?.visible ? t('input.suggestion.onScreen') : t('input.suggestion.offScreen')}
|
|
</Typography>
|
|
</Box>
|
|
{suggestion?.generating ? (
|
|
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.status.warning }}>
|
|
{t('input.suggestion.generating')}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
|
|
<Typography sx={HINT_SX}>{t('input.suggestion.keyHint')}</Typography>
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
disabled={!suggestionEnabled}
|
|
onClick={() => void window.electronAPI.suggestion.requestNow()}
|
|
sx={{ fontSize: d3roTypo.label.size }}
|
|
>
|
|
{t('input.suggestion.requestNow')}
|
|
</Button>
|
|
<Typography sx={HINT_SX}>
|
|
{t('input.suggestion.usage', {
|
|
requests: suggestion?.requestsToday ?? 0,
|
|
budget: suggestion?.dailyBudget ?? 0
|
|
})}
|
|
</Typography>
|
|
{suggestion?.lastLatencyMs !== null && suggestion?.lastLatencyMs !== undefined ? (
|
|
<Typography sx={HINT_SX}>
|
|
{t('input.suggestion.latency', { ms: suggestion.lastLatencyMs })}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
|
|
{suggestion?.lastSkipReason ? (
|
|
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
|
|
{t('input.suggestion.lastSkip', { reason: suggestion.lastSkipReason })}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|