style(settings): refine paw mode UI hierarchy and declutter status panel

This commit is contained in:
tianma-if committed 2026-09-04 01:17:30 +08:00
1 parent 7c244e0f81
commit 8a647bd599
6 files changed
+201 -59

No files matched your search

@@ -31,7 +31,7 @@ describe("quiet discovery UI", () => {
});
test("Paw mode explains benefits and exposes its inactive state without personalization controls", async () => {
const html = await render(false, "zh-CN", true);
expect(html).toContain("开启后,EdgeEver 会像猫爪轻推纸片一样,帮你归拢零散碎片、将新想法沉淀到已有笔记、挑出过往的相关灵感。只在有价值时轻量提醒,改动完全由你决定。");
expect(html).toContain("开启后,EdgeEver 会像猫爪轻推纸片一样,帮你归拢零散碎片、将新想法沉淀到已有笔记、挑出过往的相关灵感。");
expect(html).toContain("用得越久,猫爪越懂你。");
expect(html).toContain("猫爪模式");
expect(html).toContain("猫爪状态"); expect(html).toContain("未运行");
@@ -53,7 +53,7 @@ describe("quiet discovery UI", () => {
});
test("English settings have matching concise benefits and no raw translation keys", async () => {
const html = await render(false, "en-US", true);
expect(html).toContain("When enabled, EdgeEver quietly helps tuck scattered fragments together, append new ideas to existing notes, and surface relevant past insights like a gentle cat paw. It nudges you only when valuable, and all changes remain entirely up to you.");
expect(html).toContain("When enabled, EdgeEver quietly helps tuck scattered fragments together, append new ideas to existing notes, and surface relevant past insights like a gentle cat paw.");
expect(html).toContain("The longer you use it, the better Paw mode understands you.");
expect(html).toContain("Paw status"); expect(html).toContain("Not running");
expect(html).not.toContain("Personal preferences"); expect(html).not.toContain("one minute");
@@ -1,8 +1,8 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Sparkles, ShieldCheck, ChevronRight, Clock, Activity, MessageSquareText } from "lucide-react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { api } from "@/lib/api";
import { discoveryFeedKey, discoverySettingsKey, useCompanionDiscoverySettings } from "@/hooks/useCompanionDiscovery";
@@ -17,67 +17,207 @@ export function CompanionDiscoverySettingsCard({ scope, onOpenCompanion }: {
const [error, setError] = useState(false);
const settings = query.data;
const enabled = settings?.enabled === true;
const feed = useQuery({ queryKey: discoveryFeedKey(scope), queryFn: async () => (await api.listCompanionDiscoveries()).items,
enabled, staleTime: 60_000, retry: false });
const feed = useQuery({
queryKey: discoveryFeedKey(scope),
queryFn: async () => (await api.listCompanionDiscoveries()).items,
enabled,
staleTime: 60_000,
retry: false,
});
const latest = feed.data?.[0];
const formatTime = (value: string) => new Intl.DateTimeFormat(i18n.resolvedLanguage ?? "en-US", {
dateStyle: "medium", timeStyle: "short",
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
const save = async (enabled: boolean) => {
if (!settings || busy) return;
setBusy(true); setError(false);
setBusy(true);
setError(false);
try {
const result = await api.saveCompanionDiscoverySettings({ enabled, version: settings.version });
client.setQueryData(discoverySettingsKey(scope), result.settings);
client.setQueryData(discoveryFeedKey(scope), []);
} catch { setError(true); await query.refetch(); }
finally { setBusy(false); }
} catch {
setError(true);
await query.refetch();
} finally {
setBusy(false);
}
};
return <Card className="shadow-none">
<CardHeader className="p-4 sm:p-5">
<CardTitle className="text-sm">{t("companion.discovery.settingsTitle")}</CardTitle>
<CardDescription>{t("companion.discovery.description")}</CardDescription>
<p className="pt-1 text-sm font-medium text-emerald-700">{t("companion.discovery.tagline")}</p>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4 sm:px-5">
<div className="flex items-center justify-between gap-3">
<label htmlFor="companion-discovery-enabled" className="text-sm">{t("companion.discovery.enable")}</label>
<Switch id="companion-discovery-enabled" checked={settings?.enabled ?? false}
disabled={busy || !settings}
onCheckedChange={enabled => void save(enabled)} />
</div>
{error || query.isError ? <p role="alert" className="text-sm text-destructive">{t("companion.discovery.settingsFailed")}</p> : null}
<section className="space-y-3 rounded-lg border border-slate-200 bg-slate-50 p-4" aria-labelledby="companion-discovery-status-title">
<div className="flex items-center justify-between gap-3">
<h3 id="companion-discovery-status-title" className="text-sm font-medium">{t("companion.discovery.transparency.title")}</h3>
<span className="inline-flex items-center gap-2 text-sm font-medium" role="status">
<span className={`h-2 w-2 rounded-full ${enabled ? settings?.lastStatus === "failed" ? "bg-rose-500" : settings?.lastStatus === "running" ? "animate-pulse bg-amber-500" : "bg-emerald-500" : "bg-slate-400"}`} />
{t(`companion.discovery.transparency.state.${enabled ? settings?.lastStatus ?? "quiet" : "disabled"}`)}
</span>
</div>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div><dt className="text-xs text-slate-500">{t("companion.discovery.transparency.lastCheck")}</dt>
<dd className="mt-1 text-slate-700">{settings?.lastCheckAt ? formatTime(settings.lastCheckAt) : t("companion.discovery.transparency.neverChecked")}</dd></div>
<div><dt className="text-xs text-slate-500">{t("companion.discovery.transparency.nextCheck")}</dt>
<dd className="mt-1 text-slate-700">{t(enabled ? "companion.discovery.transparency.nextCheckEnabled" : "companion.discovery.transparency.nextCheckDisabled")}</dd></div>
</dl>
<p className="text-xs leading-relaxed text-slate-500">{t("companion.discovery.transparency.scope")}</p>
</section>
{enabled ? <section className="space-y-3" aria-labelledby="companion-latest-discovery-title">
<h3 id="companion-latest-discovery-title" className="text-sm font-medium">{t("companion.discovery.transparency.latest")}</h3>
{feed.isPending ? <p role="status" className="text-sm text-slate-500">{t("common.loading")}</p> : null}
{feed.isError ? <p role="alert" className="text-sm text-destructive">{t("companion.discovery.loadFailed")}</p> : null}
{!feed.isPending && !feed.isError && !latest ? <p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-slate-500">{t("companion.discovery.transparency.noDiscovery")}</p> : null}
{latest ? <article className="space-y-2 rounded-lg border border-slate-200 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-emerald-700">{t(`companion.discovery.kind.${latest.kind}`)}</p>
<time className="text-xs text-slate-500" dateTime={latest.createdAt}>{formatTime(latest.createdAt)}</time>
return (
<Card className="shadow-none">
<CardHeader className="p-4 sm:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1.5">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<CardTitle className="text-sm font-semibold sm:text-base">{t("companion.discovery.settingsTitle")}</CardTitle>
</div>
<CardDescription className="text-xs leading-relaxed text-slate-600 sm:text-sm dark:text-slate-400">
{t("companion.discovery.description")}
</CardDescription>
</div>
<h4 className="break-words text-sm font-medium text-slate-900">{latest.title}</h4>
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-600">{latest.body}</p>
</article> : null}
</section> : null}
<Button size="sm" variant="ghost" onClick={onOpenCompanion}>{t("companion.discovery.openCompanion")}</Button>
</CardContent>
</Card>;
<div className="flex shrink-0 items-center justify-between gap-3 sm:pt-0.5">
<label
htmlFor="companion-discovery-enabled"
className="cursor-pointer text-sm font-medium text-slate-700 dark:text-slate-300"
>
{t("companion.discovery.enable")}
</label>
<Switch
id="companion-discovery-enabled"
checked={settings?.enabled ?? false}
disabled={busy || !settings}
onCheckedChange={(enabled) => void save(enabled)}
/>
</div>
</div>
<div className="mt-2 flex items-center gap-2 rounded-md bg-emerald-50/80 px-3 py-2 text-xs text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300">
<span className="text-sm leading-none" aria-hidden="true">🐾</span>
<p className="font-medium text-emerald-700 dark:text-emerald-300">{t("companion.discovery.tagline")}</p>
</div>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-5 sm:px-5">
{error || query.isError ? (
<p role="alert" className="text-sm text-destructive">
{t("companion.discovery.settingsFailed")}
</p>
) : null}
<section
className="space-y-3 rounded-lg border border-slate-200/80 bg-white p-4 dark:border-slate-800 dark:bg-slate-900"
aria-labelledby="companion-discovery-status-title"
>
<div className="flex items-center justify-between gap-3 border-b border-slate-200/60 pb-3 dark:border-slate-800/60">
<h3
id="companion-discovery-status-title"
className="flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-slate-100"
>
<Activity className="h-4 w-4 text-slate-500 dark:text-slate-400" />
{t("companion.discovery.transparency.title")}
</h3>
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${
enabled
? settings?.lastStatus === "failed"
? "border border-rose-200/80 bg-rose-50 text-rose-700 dark:border-rose-900/40 dark:bg-rose-950/40 dark:text-rose-300"
: settings?.lastStatus === "running"
? "border border-amber-200/80 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-300"
: "border border-emerald-200/80 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/40 dark:text-emerald-300"
: "border border-slate-200 bg-slate-100 text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400"
}`}
role="status"
>
<span
className={`h-1.5 w-1.5 rounded-full ${
enabled
? settings?.lastStatus === "failed"
? "bg-rose-500"
: settings?.lastStatus === "running"
? "animate-pulse bg-amber-500"
: "bg-emerald-500"
: "bg-slate-400"
}`}
/>
{t(`companion.discovery.transparency.state.${enabled ? settings?.lastStatus ?? "quiet" : "disabled"}`)}
</span>
</div>
<dl className="space-y-2.5 text-xs sm:text-sm">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<dt className="flex items-center gap-1.5 font-medium text-slate-500 dark:text-slate-400">
<Clock className="h-3.5 w-3.5 text-slate-400 dark:text-slate-500" />
{t("companion.discovery.transparency.lastCheck")}
</dt>
<dd className="font-medium text-slate-800 dark:text-slate-200">
{settings?.lastCheckAt ? formatTime(settings.lastCheckAt) : t("companion.discovery.transparency.neverChecked")}
</dd>
</div>
<div className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between">
<dt className="flex shrink-0 items-center gap-1.5 font-medium text-slate-500 dark:text-slate-400">
<Sparkles className="h-3.5 w-3.5 text-slate-400 dark:text-slate-500" />
{t("companion.discovery.transparency.nextCheck")}
</dt>
<dd className="text-xs leading-relaxed text-slate-600 sm:max-w-md sm:text-right dark:text-slate-300">
{t(enabled ? "companion.discovery.transparency.nextCheckEnabled" : "companion.discovery.transparency.nextCheckDisabled")}
</dd>
</div>
</dl>
<div className="flex items-start gap-2 border-t border-slate-200/60 pt-2.5 dark:border-slate-800/60">
<ShieldCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<p className="text-xs leading-relaxed text-slate-500 dark:text-slate-400">
{t("companion.discovery.transparency.scope")}
</p>
</div>
</section>
{enabled ? (
<section className="space-y-3" aria-labelledby="companion-latest-discovery-title">
<h3
id="companion-latest-discovery-title"
className="text-sm font-medium text-slate-900 dark:text-slate-100"
>
{t("companion.discovery.transparency.latest")}
</h3>
{feed.isPending ? <p role="status" className="text-sm text-slate-500">{t("common.loading")}</p> : null}
{feed.isError ? <p role="alert" className="text-sm text-destructive">{t("companion.discovery.loadFailed")}</p> : null}
{!feed.isPending && !feed.isError && !latest ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-slate-200/80 bg-white px-4 py-6 text-center dark:border-slate-800 dark:bg-slate-900">
<div className="mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-emerald-50 text-emerald-600 dark:bg-emerald-950/50 dark:text-emerald-400">
<Sparkles className="h-4 w-4" />
</div>
<p className="max-w-md text-xs text-slate-500 sm:text-sm dark:text-slate-400">
{t("companion.discovery.transparency.noDiscovery")}
</p>
</div>
) : null}
{latest ? (
<article className="space-y-2.5 rounded-lg border border-slate-200/80 bg-white p-4 shadow-sm transition-colors dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 pb-2 dark:border-slate-800">
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300">
{t(`companion.discovery.kind.${latest.kind}`)}
</span>
<time className="text-xs text-slate-400 dark:text-slate-500" dateTime={latest.createdAt}>
{formatTime(latest.createdAt)}
</time>
</div>
<h4 className="break-words text-sm font-semibold text-slate-900 dark:text-slate-100">{latest.title}</h4>
<p className="whitespace-pre-wrap break-words text-xs leading-relaxed text-slate-600 sm:text-sm dark:text-slate-300">
{latest.body}
</p>
</article>
) : null}
</section>
) : null}
<div className="pt-1">
<button
type="button"
onClick={onOpenCompanion}
className="group flex w-full items-center justify-between rounded-lg border border-slate-200/80 bg-white p-3.5 text-left transition-all hover:border-emerald-300 hover:bg-emerald-50/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-emerald-700/60 dark:hover:bg-emerald-950/20"
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-slate-200/60 bg-slate-50 text-emerald-600 shadow-sm transition-colors group-hover:border-emerald-200 group-hover:bg-emerald-50 group-hover:text-emerald-700 dark:border-slate-800 dark:bg-slate-800 dark:text-emerald-400">
<MessageSquareText className="h-4.5 w-4.5" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900 transition-colors group-hover:text-emerald-800 dark:text-slate-100 dark:group-hover:text-emerald-300">
{t("companion.discovery.openCompanion")}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{t("companion.discovery.openCompanionDesc")}
</div>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5 group-hover:text-emerald-600 dark:text-slate-500 dark:group-hover:text-emerald-400" />
</button>
</div>
</CardContent>
</Card>
);
}
+1 -1
View File
@@ -22,7 +22,7 @@ The main flow is: normal note-taking → the Agent identifies an opportunity at
The settings page starts with a short benefit description, then makes the background behavior visible without adding configuration burden. A read-only status panel shows whether Paw mode is idle, checking, ready, or failed; the last check time; the conditions for another check; the data scope and confirmation boundary; and the latest generated discovery when one exists:
> When enabled, EdgeEver quietly helps tuck scattered fragments together, append new ideas to existing notes, and surface relevant past insights like a gentle cat paw. It nudges you only when valuable, and all changes remain entirely up to you.
> When enabled, EdgeEver quietly helps tuck scattered fragments together, append new ideas to existing notes, and surface relevant past insights like a gentle cat paw.
>
> The longer you use it, the better Paw mode understands you.
+1 -1
View File
@@ -22,7 +22,7 @@ Agent 是围绕用户知识工作的长期助手,不是一个熟悉笔记的
设置页以简短用途介绍开头,再以只读状态呈现后台行为,不增加配置负担。状态面板展示猫爪处于安静观察、检查中、发现就绪或检查失败,最近检查时间、下次检查条件、数据范围与确认边界;存在生成结果时还展示最近一条发现:
> 开启后,EdgeEver 会像猫爪轻推纸片一样,帮你归拢零散碎片、将新想法沉淀到已有笔记、挑出过往的相关灵感。只在有价值时轻量提醒,改动完全由你决定。
> 开启后,EdgeEver 会像猫爪轻推纸片一样,帮你归拢零散碎片、将新想法沉淀到已有笔记、挑出过往的相关灵感。
>
> 用得越久,猫爪越懂你。
+2 -1
View File
@@ -6,11 +6,12 @@ export const enUS = {
discovery: {
settingsTitle: "Paw mode",
title: "Discoveries from EdgeEver",
description: "When enabled, EdgeEver quietly helps tuck scattered fragments together, append new ideas to existing notes, and surface relevant past insights like a gentle cat paw. It nudges you only when valuable, and all changes remain entirely up to you.",
description: "When enabled, EdgeEver quietly helps tuck scattered fragments together, append new ideas to existing notes, and surface relevant past insights like a gentle cat paw.",
tagline: "The longer you use it, the better Paw mode understands you.",
enable: "Enable Paw mode",
settingsFailed: "Settings were not saved. Check your connection or refresh and retry.",
openCompanion: "Conversation and personal memory",
openCompanionDesc: "Manage memories and context preferences Paw mode learns from conversations.",
panelDescription: "No need to process every item. Only approved operations change notes; discoveries disappear after 7 days.",
unread: "New discoveries",
empty: "Nothing worth interrupting you for yet. Keep capturing your ideas.",
+2 -1
View File
@@ -6,11 +6,12 @@ export const zhCN = {
discovery: {
settingsTitle: "猫爪模式",
title: "来自 EdgeEver 的发现",
description: "开启后,EdgeEver 会像猫爪轻推纸片一样,帮你归拢零散碎片、将新想法沉淀到已有笔记、挑出过往的相关灵感。只在有价值时轻量提醒,改动完全由你决定。",
description: "开启后,EdgeEver 会像猫爪轻推纸片一样,帮你归拢零散碎片、将新想法沉淀到已有笔记、挑出过往的相关灵感。",
tagline: "用得越久,猫爪越懂你。",
enable: "启用猫爪模式",
settingsFailed: "设置未保存。请检查连接或刷新后重试。",
openCompanion: "对话与个人记忆",
openCompanionDesc: "管理猫爪在日常对话中沉淀的记忆与上下文偏好。",
panelDescription: "不需要逐条处理。只有你确认的操作才会改变笔记;发现 7 天后自动隐藏。",
unread: "有新发现",
empty: "暂时没有值得打扰你的发现。继续记录就好。",