release: ship v1.5.0 with on-device writing suggestions
Some checks failed
deploy-site / deploy (push) Failing after 33s
portable-unsigned / portable-windows (push) Failing after 4m7s
release / release-windows (push) Failing after 3m16s

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.
This commit is contained in:
Yun Chan 2026-09-23 16:04:27 +09:00
parent 99f06c253c
commit 5c11ee2fde
104 changed files with 14410 additions and 174 deletions

View file

@ -0,0 +1,163 @@
// src/renderer/components/input-insights/BarChart.tsx
// 입력 통계용 막대 그래프.
//
// 이전 구현은 flex 비율 + % 높이만 써서, 데이터가 1~2일치일 때 막대 하나가 화면을
// 가득 채우는 사각형이 됐고 축/라벨도 없어 "그래프"로 읽을 수 없었다. 그래서
// - 막대 폭을 고정 상한(24px)으로 두어 데이터가 적어도 거대해지지 않게 하고
// - 값 축(최대값)과 라벨 행을 항상 함께 그리며
// - 값이 0 뿐이면 그래프 대신 빈 상태 문구를 보여준다.
import { Box, Typography } from '@mui/material'
import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
export interface BarDatum {
/** x축 라벨 */
label: string
value: number
/** 툴팁/접근성 텍스트 */
hint?: string
}
interface BarChartProps {
data: readonly BarDatum[]
/** 그래프 높이 (px) */
height?: number
/** 값 포맷 (기본: 정수) */
formatValue?: (value: number) => string
/** 라벨을 몇 개마다 표시할지 */
labelEvery?: number
accent?: string
}
export function BarChart({
data,
height = 110,
formatValue = (value) => value.toLocaleString(),
labelEvery = 1,
accent = d3roPalette.accent.main
}: BarChartProps): React.ReactElement {
const { t } = useI18n()
const max = Math.max(1, ...data.map((item) => item.value))
const hasData = data.some((item) => item.value > 0)
if (data.length === 0 || !hasData) {
return (
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.inactive, py: 2 }}>
{t('input.chart.noData')}
</Typography>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.dimLabel }}>
{t('input.chart.max', { value: formatValue(max) })}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'flex-end',
gap: '2px',
height,
px: 0.25,
borderBottom: `1px solid ${d3roPalette.border.subtle}`
}}
>
{data.map((item, index) => (
<Box
key={`${item.label}-${index}`}
title={item.hint ?? `${item.label}: ${formatValue(item.value)}`}
sx={{
flex: '0 1 24px',
minWidth: item.value > 0 ? '5px' : '2px',
height: `${Math.max(2, (item.value / max) * 100)}%`,
borderRadius: `${d3roRadius.xs} ${d3roRadius.xs} 0 0`,
bgcolor: item.value > 0 ? accent : d3roPalette.border.subtle,
opacity: item.value > 0 ? 0.85 : 1,
transition: 'opacity 120ms ease-out',
'&:hover': { opacity: 1 }
}}
/>
))}
</Box>
<Box sx={{ display: 'flex', gap: '2px', px: 0.25 }}>
{data.map((item, index) => (
<Box
key={`${item.label}-label-${index}`}
sx={{ flex: '0 1 24px', minWidth: '5px', textAlign: 'center', overflow: 'hidden' }}
>
{index % labelEvery === 0 ? (
<Typography
sx={{
...typoSx('nano'),
color: d3roPalette.text.inactive,
whiteSpace: 'nowrap',
overflow: 'hidden'
}}
>
{item.label}
</Typography>
) : null}
</Box>
))}
</Box>
</Box>
)
}
/** 값 비중을 가로 막대로 보여주는 목록 행 (앱 비중 등). */
export function ShareBar({
label,
value,
total,
right
}: {
label: string
value: number
total: number
right?: string
}): React.ReactElement {
const percent = total > 0 ? value / total : 0
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }}>
<Typography
sx={{
...typoSx('small'),
color: d3roPalette.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{label}
</Typography>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.inactive, whiteSpace: 'nowrap' }}>
{right ?? `${Math.round(percent * 100)}%`}
</Typography>
</Box>
<Box
sx={{
height: 6,
borderRadius: 3,
bgcolor: d3roPalette.bg.inset,
overflow: 'hidden'
}}
>
<Box
sx={{
width: `${Math.max(2, Math.round(percent * 100))}%`,
height: '100%',
bgcolor: d3roPalette.accent.main,
opacity: 0.8
}}
/>
</Box>
</Box>
)
}

View file

@ -0,0 +1,555 @@
// 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>
)
}

View file

@ -0,0 +1,701 @@
// src/renderer/components/input-insights/InputInsightsView.tsx
// 입력 인사이트 — 지식 베이스 안에 들어가는 상세 통계 화면.
//
// 종류별 inner tab 으로 나눈다: 요약 / 키보드 / 마우스 / 앱 / 문구·제안.
// 모든 수치는 수집된 로컬 집계(input_activity)와 제안 이력(suggestions)에서 나온다.
import { useCallback, useEffect, useState } from 'react'
import {
Box,
Button,
Divider,
IconButton,
Tab,
Tabs,
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 { BarChart, ShareBar, type BarDatum } from './BarChart'
import { useInputInsights } from '../../hooks/useInputInsights'
import type {
PersonalGraphQuery,
PersonalGraphStats
} from '@d3ro/core/personal-graph'
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
interface TileProps {
label: string
value: string
unit?: string
emphasis?: boolean
}
function StatTile({ label, value, unit, emphasis = false }: TileProps): React.ReactElement {
return (
<Box
sx={{
flex: '1 1 132px',
minWidth: 132,
p: 1.25,
borderRadius: d3roRadius.inner,
border: `1px solid ${emphasis ? d3roPalette.accent.main : d3roPalette.border.subtle}`,
bgcolor: emphasis ? d3roPalette.accent.dim : d3roPalette.bg.inset,
display: 'flex',
flexDirection: 'column',
gap: 0.25
}}
>
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>{label}</Typography>
<Typography sx={{ ...typoSx('value'), color: d3roPalette.text.primary }}>
{value}
{unit ? (
<Box component="span" sx={{ ml: 0.5, ...typoSx('small'), color: d3roPalette.text.inactive }}>
{unit}
</Box>
) : null}
</Typography>
</Box>
)
}
function TileRow({ children }: { children: React.ReactNode }): React.ReactElement {
return <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>{children}</Box>
}
function Section({ title, children }: { title: string; children: React.ReactNode }): React.ReactElement {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<Typography variant="overline" sx={OVERLINE_SX}>
{title}
</Typography>
{children}
</Box>
)
}
interface SuggestionHistoryRow {
id: string
appName: string | null
prefixText: string
suggestionText: string
model: string | null
latencyMs: number | null
accepted: boolean
createdAt: number
}
export function InputInsightsView(): React.ReactElement {
const { t, formatRelativeDate } = useI18n()
const [days, setDays] = useState(7)
const [tab, setTab] = useState(0)
const [history, setHistory] = useState<SuggestionHistoryRow[]>([])
const [graph, setGraph] = useState<PersonalGraphStats | null>(null)
const [graphQuery, setGraphQuery] = useState('')
const [graphResult, setGraphResult] = useState<PersonalGraphQuery | null>(null)
const { telemetry, summary, phrases, refresh } = useInputInsights(days)
const loadHistory = useCallback(async (): Promise<void> => {
const result = await window.electronAPI.suggestion.getHistory({ limit: 20 })
if (result.success) setHistory(result.data)
}, [])
useEffect(() => {
void loadHistory()
}, [loadHistory])
useEffect(() => {
let cancelled = false
void window.electronAPI.inputTelemetry.getGraph().then((result) => {
if (!cancelled && result.success) setGraph(result.data)
})
return () => {
cancelled = true
}
}, [tab, phrases.length])
const runGraphQuery = useCallback(async (): Promise<void> => {
const result = await window.electronAPI.inputTelemetry.queryGraph({ text: graphQuery })
if (result.success) setGraphResult(result.data)
}, [graphQuery])
const handleDeletePhrase = useCallback(
async (id: string): Promise<void> => {
await window.electronAPI.inputTelemetry.deletePhrase({ id })
await refresh()
},
[refresh]
)
const dailyKeys: BarDatum[] = (summary?.daily ?? []).map((day) => ({
label: day.date.slice(5),
value: day.keystrokes,
hint: `${day.date} · ${day.keystrokes.toLocaleString()}`
}))
const dailyWords: BarDatum[] = (summary?.daily ?? []).map((day) => ({
label: day.date.slice(5),
value: day.words,
hint: `${day.date} · ${day.words.toLocaleString()}`
}))
const dailyDistance: BarDatum[] = (summary?.daily ?? []).map((day) => ({
label: day.date.slice(5),
value: Math.round(day.mouseDistancePx / 96 / 0.0254),
hint: `${day.date} · ${Math.round(day.mouseDistancePx)}px`
}))
const hourlyKeys: BarDatum[] = (summary?.hourly ?? []).map((hour) => ({
label: String(hour.hour),
value: hour.keystrokes,
hint: `${hour.hour}:00 · ${hour.keystrokes.toLocaleString()}`
}))
const labelEvery = days > 14 ? 3 : days > 7 ? 2 : 1
const totals = summary?.totals
const averages = summary?.averages
const suggestions = summary?.suggestions
const friction = summary?.friction
const flowWindows = summary?.flowWindows ?? []
const suggestionApps = summary?.suggestionApps ?? []
const frictionBandLabel =
friction?.band === 'high'
? t('input.friction.band.high')
: friction?.band === 'watch'
? t('input.friction.band.watch')
: t('input.friction.band.steady')
const topAppTotal = (summary?.topApps ?? []).reduce((sum, app) => sum + app.keystrokes, 0)
if (!telemetry?.enabled) {
return (
<Box sx={{ py: 4, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={HINT_SX}>{t('input.insights.disabledHint')}</Typography>
</Box>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>
{t('input.insights.rangeLabel')}
</Typography>
{[7, 14, 30].map((value) => (
<Box
key={value}
component="button"
type="button"
onClick={() => setDays(value)}
sx={{
cursor: 'pointer',
px: 1,
py: 0.25,
borderRadius: d3roRadius.xs,
border: `1px solid ${value === days ? d3roPalette.accent.main : d3roPalette.border.subtle}`,
bgcolor: value === days ? d3roPalette.accent.dim : 'transparent',
color: value === days ? d3roPalette.accent.main : d3roPalette.text.inactive,
font: 'inherit',
...typoSx('micro')
}}
>
{t('input.insights.rangeDays', { days: value })}
</Box>
))}
<Typography sx={{ ...HINT_SX, ml: 'auto' }}>
{t('input.insights.headerSummary', {
days: summary?.days ?? days,
keys: totals?.keystrokes ?? 0,
apps: summary?.topApps.length ?? 0
})}
</Typography>
</Box>
<Tabs
value={tab}
onChange={(_event, next: number) => setTab(next)}
variant="scrollable"
scrollButtons={false}
sx={{
minHeight: 36,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
'& .MuiTab-root': {
minHeight: 36,
py: 0.5,
textTransform: 'none',
color: d3roPalette.text.inactive,
fontSize: d3roTypo.label.size,
'&.Mui-selected': { color: d3roPalette.accent.main } as const
},
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 }
}}
>
<Tab label={t('input.insights.tabs.overview')} />
<Tab label={t('input.insights.tabs.keyboard')} />
<Tab label={t('input.insights.tabs.mouse')} />
<Tab label={t('input.insights.tabs.apps')} />
<Tab label={t('input.insights.tabs.phrases')} />
<Tab label={t('input.insights.tabs.graph')} />
</Tabs>
{/* ── 요약 ─────────────────────────────────────── */}
{tab === 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TileRow>
<StatTile label={t('input.insights.keystrokes')} value={(totals?.keystrokes ?? 0).toLocaleString()} emphasis />
<StatTile label={t('input.insights.clicks')} value={(totals?.clicks ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.words')} value={(totals?.words ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.chars')} value={(totals?.chars ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.sentences')} value={(totals?.sentences ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.activeMinutes')} value={String(averages?.activeMinutes ?? 0)} unit={t('input.unit.perDay')} />
<StatTile label={t('input.insights.mouseDistance')} value={String(averages?.mouseDistanceMeters ?? 0)} unit={t('input.unit.perDayMeters')} />
<StatTile label={t('input.insights.activeDays')} value={String(summary?.activeDays ?? 0)} />
<StatTile label={t('input.insights.streak')} value={String(summary?.longestStreakDays ?? 0)} />
<StatTile
label={t('input.insights.peakDay')}
value={summary?.peakDay ? summary.peakDay.date.slice(5) : '—'}
unit={summary?.peakDay ? summary.peakDay.keystrokes.toLocaleString() : undefined}
/>
<StatTile label={t('input.insights.phrases')} value={String(summary?.phraseCount ?? 0)} />
<StatTile
label={t('input.insights.acceptRate')}
value={suggestions ? `${Math.round(suggestions.acceptRate * 100)}` : '0'}
unit={t('input.unit.percent')}
/>
</TileRow>
<Section title={t('input.insights.dailyTitle')}>
<BarChart data={dailyKeys} labelEvery={labelEvery} />
</Section>
<Section title={t('input.insights.perDayAverage')}>
<TileRow>
<StatTile label={t('input.insights.keystrokes')} value={(averages?.keystrokes ?? 0).toLocaleString()} unit={t('input.unit.perDay')} />
<StatTile label={t('input.insights.words')} value={(averages?.words ?? 0).toLocaleString()} unit={t('input.unit.perDay')} />
<StatTile label={t('input.insights.clicks')} value={(averages?.clicks ?? 0).toLocaleString()} unit={t('input.unit.perDay')} />
<StatTile label={t('input.insights.backspaces')} value={(averages?.backspaces ?? 0).toLocaleString()} unit={t('input.unit.perDay')} />
</TileRow>
</Section>
</Box>
) : null}
{/* ── 키보드 ───────────────────────────────────── */}
{tab === 1 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TileRow>
<StatTile label={t('input.insights.keystrokes')} value={(totals?.keystrokes ?? 0).toLocaleString()} emphasis />
<StatTile label={t('input.insights.wordChars')} value={(totals?.chars ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.words')} value={(totals?.words ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.sentences')} value={(totals?.sentences ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.backspaces')} value={(totals?.backspaces ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.shortcuts')} value={(totals?.shortcuts ?? 0).toLocaleString()} />
</TileRow>
<Section title={t('input.insights.hourlyTitle')}>
<BarChart data={hourlyKeys} labelEvery={2} />
</Section>
<Section title={t('input.flow.title')}>
<TileRow>
<StatTile
label={t('input.friction.title')}
value={t('input.friction.value', { count: friction?.editsPer100Chars ?? 0 })}
unit={frictionBandLabel}
/>
</TileRow>
{flowWindows.length === 0 ? (
<Typography sx={HINT_SX}>{t('input.flow.empty')}</Typography>
) : (
flowWindows.map((window) => (
<Box
key={window.hour}
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.75,
alignItems: 'center',
px: 1,
py: 0.75,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.border.subtle}`,
bgcolor: d3roPalette.bg.inset
}}
>
<Typography sx={{ ...typoSx('compact'), color: d3roPalette.text.primary }}>
{t('input.flow.hour', { hour: window.hour })}
</Typography>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.secondary }}>
{t('input.flow.window', {
score: window.score,
minutes: Math.round(window.activeMinutes),
friction: Math.round(window.frictionRate * 100)
})}
</Typography>
</Box>
))
)}
</Section>
<Section title={t('input.insights.wordsDailyTitle')}>
<BarChart data={dailyWords} labelEvery={labelEvery} accent={d3roPalette.accent.light} />
</Section>
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
{t('input.insights.topHoursHint', {
hours: (summary?.topHours ?? [])
.slice(0, 3)
.map((hour) => `${hour.hour}시`)
.join(', ')
})}
</Typography>
</Box>
) : null}
{/* ── 마우스 ───────────────────────────────────── */}
{tab === 2 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TileRow>
<StatTile label={t('input.insights.clicks')} value={(totals?.clicks ?? 0).toLocaleString()} emphasis />
<StatTile label={t('input.insights.doubleClicks')} value={(totals?.doubleClicks ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.scrollTicks')} value={(totals?.scrollTicks ?? 0).toLocaleString()} />
<StatTile
label={t('input.insights.mouseDistanceTotal')}
value={((averages?.mouseDistanceMeters ?? 0) * days).toFixed(1)}
unit={t('input.unit.meters')}
/>
</TileRow>
<Section title={t('input.insights.distanceDailyTitle')}>
<BarChart data={dailyDistance} labelEvery={labelEvery} accent={d3roPalette.accent.light} />
</Section>
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
{t('input.insights.mouseNote')}
</Typography>
</Box>
) : null}
{/* ── 앱 ──────────────────────────────────────── */}
{tab === 3 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography sx={HINT_SX}>{t('input.insights.appsHint')}</Typography>
{(summary?.topApps ?? []).length === 0 ? (
<Typography sx={HINT_SX}>{t('input.insights.empty')}</Typography>
) : (
(summary?.topApps ?? []).map((app) => (
<ShareBar
key={app.appName}
label={app.appName}
value={app.keystrokes}
total={topAppTotal}
right={t('input.insights.appRow', {
keystrokes: app.keystrokes,
clicks: app.clicks
})}
/>
))
)}
<Section title={t('input.appQuality.title')}>
{suggestionApps.length === 0 ? (
<Typography sx={HINT_SX}>{t('input.appQuality.empty')}</Typography>
) : (
suggestionApps.map((app) => (
<Box
key={app.appName}
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.75,
alignItems: 'center',
px: 1,
py: 0.75,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.border.subtle}`,
bgcolor: d3roPalette.bg.inset
}}
>
<Typography
sx={{
flex: '1 1 120px',
minWidth: 0,
...typoSx('compact'),
color: d3roPalette.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{app.appName}
</Typography>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.secondary }}>
{t('input.appQuality.row', {
accepted: app.accepted,
total: app.total,
rate: Math.round(app.acceptRate * 100),
latency: app.avgLatencyMs ?? '—'
})}
</Typography>
</Box>
))
)}
</Section>
</Box>
) : null}
{/* ── 개인 그래프 ──────────────────────────────── */}
{tab === 5 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography sx={HINT_SX}>{t('input.graph.description')}</Typography>
<TileRow>
<StatTile label={t('input.graph.nodes')} value={String(graph?.nodes ?? 0)} emphasis />
<StatTile label={t('input.graph.followsEdges')} value={String(graph?.followsEdges ?? 0)} />
<StatTile label={t('input.graph.sharesEdges')} value={String(graph?.sharesTermsEdges ?? 0)} />
</TileRow>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
size="small"
fullWidth
value={graphQuery}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setGraphQuery(event.target.value)}
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') void runGraphQuery()
}}
placeholder={t('input.graph.searchPlaceholder')}
label={t('input.graph.searchLabel')}
/>
<Button size="small" variant="outlined" onClick={() => void runGraphQuery()}>
{t('input.graph.searchAction')}
</Button>
</Box>
{graphResult && graphResult.anchors.length > 0 ? (
<Section title={t('input.graph.neighbors')}>
{graphResult.anchors.map((anchor) => (
<Box key={anchor.text} sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Typography sx={{ ...typoSx('compact'), color: d3roPalette.accent.main }}>
{anchor.text}
</Typography>
{graphResult.neighbors
.slice(0, 4)
.map((neighbor) => (
<Typography
key={`${anchor.text}-${neighbor.text}`}
sx={{ ...typoSx('small'), color: d3roPalette.text.secondary, pl: 1.5 }}
>
→ {neighbor.text} ({neighbor.kind === 'follows' ? t('input.graph.kindFollows') : t('input.graph.kindShares')} · {neighbor.weight})
</Typography>
))}
</Box>
))}
</Section>
) : null}
<Section title={t('input.graph.topEdges')}>
{(graph?.topEdges ?? []).length === 0 ? (
<Typography sx={HINT_SX}>{t('input.graph.empty')}</Typography>
) : (
(graph?.topEdges ?? []).map((edge) => (
<Box
key={`${edge.from}-${edge.to}-${edge.kind}`}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1,
py: 0.5,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.border.subtle}`
}}
>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.dimLabel }}>
{edge.kind === 'follows' ? t('input.graph.kindFollows') : t('input.graph.kindShares')}
</Typography>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{edge.from} → {edge.to}
</Typography>
<Typography
sx={{
minWidth: 0,
maxWidth: '45%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
...typoSx('micro'),
color: d3roPalette.text.inactive
}}
>
{edge.weight}×
</Typography>
</Box>
))
)}
</Section>
<Section title={t('input.graph.recentNodes')}>
{(graph?.recentNodes ?? []).slice(0, 8).map((node) => (
<Box key={node.text} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{node.text}
</Typography>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.inactive }}>
{node.count}× · {node.appName ?? '—'}
</Typography>
</Box>
))}
</Section>
</Box>
) : null}
{/* ── 문구 · 제안 ─────────────────────────────── */}
{tab === 4 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Section title={t('input.insights.suggestions')}>
<TileRow>
<StatTile label={t('input.insights.suggestionsTotal')} value={(suggestions?.total ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.suggestionsAccepted')} value={(suggestions?.accepted ?? 0).toLocaleString()} />
<StatTile
label={t('input.insights.acceptRate')}
value={String(Math.round((suggestions?.acceptRate ?? 0) * 100))}
unit={t('input.unit.percent')}
/>
<StatTile
label={t('input.insights.avgLatency')}
value={suggestions?.avgLatencyMs === null || suggestions?.avgLatencyMs === undefined ? '—' : String(suggestions.avgLatencyMs)}
unit={t('input.unit.ms')}
/>
</TileRow>
</Section>
<Section title={t('input.insights.suggestionHistory')}>
{history.length === 0 ? (
<Typography sx={HINT_SX}>{t('input.insights.noSuggestions')}</Typography>
) : (
history.map((entry) => (
<Box
key={entry.id}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.25,
px: 1.25,
py: 1,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.border.subtle}`
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
sx={{
...typoSx('micro'),
color: entry.accepted ? d3roPalette.status.success : d3roPalette.text.inactive
}}
>
{entry.accepted ? t('input.insights.accepted') : t('input.insights.notAccepted')}
</Typography>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.dimLabel }}>
{entry.appName ?? '—'}
</Typography>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.inactive, ml: 'auto' }}>
{entry.latencyMs === null ? '' : `${entry.latencyMs}ms`}
</Typography>
</Box>
<Typography
sx={{
...typoSx('compact'),
color: d3roPalette.text.secondary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{entry.prefixText.slice(-40)} → {entry.suggestionText}
</Typography>
</Box>
))
)}
</Section>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Section title={t('input.phrases.title')}>
<Typography sx={HINT_SX}>{t('input.phrases.description')}</Typography>
{phrases.length === 0 ? (
<Typography sx={HINT_SX}>{t('input.phrases.empty')}</Typography>
) : (
phrases.slice(0, 40).map((phrase) => (
<Box
key={phrase.id}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1,
py: 0.5,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.border.subtle}`
}}
>
<Typography
sx={{
flex: 1,
minWidth: 0,
...typoSx('compact'),
color: d3roPalette.text.secondary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{phrase.phrase}
</Typography>
<Typography
sx={{
minWidth: 0,
maxWidth: '45%',
flexShrink: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
...typoSx('micro'),
color: d3roPalette.text.inactive
}}
>
{t('input.phrases.metadata', {
source: phrase.source,
count: phrase.count,
app: phrase.appName ?? t('input.phrases.appUnknown')
})}
</Typography>
<IconButton
size="small"
aria-label={t('input.phrases.delete')}
onClick={() => void handleDeletePhrase(phrase.id)}
sx={{ flexShrink: 0 }}
>
<Trash2 size={13} />
</IconButton>
</Box>
))
)}
</Section>
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
{t('input.insights.samplesNote', { count: summary?.sampleCount ?? 0 })}{' '}
{t('input.insights.collectedAt', { at: formatRelativeDate(telemetry.lastSnapshotAt || Date.now()) })}
</Typography>
</Box>
) : null}
</Box>
)
}