feat(ai): retire Paw mode in favor of the assistant Agent

Remove the standalone companion page, discovery hub, and settings tab. Keep the companion runtime, MCP tools, and confirmation cards for Agent mode in the AI assistant.
This commit is contained in:
tianma-if committed 2026-09-15 22:59:15 +08:00
1 parent a54f297ec6
commit 5780e009db
25 files changed
+35 -1098

No files matched your search

-1
View File
@@ -229,7 +229,6 @@ export const App = () => {
<Route path="/plugins/:pluginId" element={<AuthenticatedWorkspace />} />
<Route path="/templates" element={<AuthenticatedWorkspace />} />
<Route path="/ai-prompts" element={<AuthenticatedWorkspace />} />
<Route path="/companion" element={<AuthenticatedWorkspace />} />
<Route path="/execution-center" element={<AuthenticatedWorkspace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+9 -49
View File
@@ -3,8 +3,6 @@ import { useTranslation } from "react-i18next";
import { MessageCircle } from "lucide-react";
import type { CompanionAction, CompanionTurn, CompanionTurnInput } from "@edgeever/shared";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { api, ApiRequestError } from "@/lib/api";
import { companionLocale } from "@/lib/companion-locale";
import { CompanionActionCard } from "./CompanionActionCard";
@@ -19,9 +17,6 @@ export type CompanionChatFocus = {
type CompanionChatProps = {
available: boolean;
variant: "page" | "assistant";
allowWrites: boolean;
forceAllowNotes?: boolean;
focus?: CompanionChatFocus;
placeholder?: string;
beforeApply: () => Promise<void>;
@@ -31,9 +26,6 @@ type CompanionChatProps = {
export function CompanionChat({
available,
variant,
allowWrites,
forceAllowNotes = false,
focus,
placeholder,
beforeApply,
@@ -41,13 +33,10 @@ export function CompanionChat({
onOpenNote,
}: CompanionChatProps) {
const { t, i18n } = useTranslation();
const compact = variant === "assistant";
const [turns, setTurns] = useState<CompanionTurn[]>([]);
const [actions, setActions] = useState<CompanionAction[]>([]);
const [threadId, setThreadId] = useState<string>(() => crypto.randomUUID());
const [message, setMessage] = useState("");
const [useMemory, setUseMemory] = useState(false);
const [allowNotes, setAllowNotes] = useState(forceAllowNotes);
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -67,13 +56,11 @@ export function CompanionChat({
return t("companion.failed");
};
const reload = async () => {
const [turnResult, actionResult, settingsResult] = await Promise.all([
const [turnResult, actionResult] = await Promise.all([
api.listCompanionTurns(),
api.listCompanionActions(),
api.getCompanionDiscoverySettings(),
]);
if (!alive.current) return;
setUseMemory(settingsResult.settings.useMemory === true);
setTurns(turnResult.turns);
setActions(actionResult.actions);
};
@@ -91,10 +78,6 @@ export function CompanionChat({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [available]);
useEffect(() => {
if (forceAllowNotes) setAllowNotes(true);
}, [forceAllowNotes]);
const perform = async (work: () => Promise<unknown>) => {
if (locked.current) return;
locked.current = true;
@@ -117,9 +100,9 @@ export function CompanionChat({
inputTokens: null, outputTokens: null, createdAt: new Date().toISOString() }, ...previous]);
let completed = false;
const payload: CompanionTurnInput = {
id, threadId, message: text, useMemory,
allowNotes: forceAllowNotes || allowNotes,
allowWrites,
id, threadId, message: text, useMemory: false,
allowNotes: true,
allowWrites: true,
locale: companionLocale(i18n.resolvedLanguage),
...(focus?.memoId || focus?.selectionMarkdown?.trim()
? { focus: {
@@ -179,7 +162,7 @@ export function CompanionChat({
const threads = [...new Map(turns.map(turn => [turn.threadId, turn])).values()];
const running = turns.find(turn => turn.status === "running");
const threadTurns = turns.filter(turn => turn.threadId === threadId).reverse();
const previousThread = compact ? threads.find(thread => thread.threadId !== threadId) : undefined;
const previousThread = threads.find(thread => thread.threadId !== threadId);
if (!available) {
return <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 overflow-y-auto p-4 text-center">
@@ -191,23 +174,11 @@ export function CompanionChat({
return <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
{error ? <p role="alert" className="text-sm text-rose-700">{error}</p> : null}
{loading ? <p role="status">{t("common.loading")}</p> : null}
{!compact ? <div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<Select value={threadId} disabled={busy} onValueChange={setThreadId}>
<SelectTrigger aria-label={t("companion.history")}><SelectValue /></SelectTrigger>
<SelectContent>
{!threads.some(thread => thread.threadId === threadId) ? <SelectItem value={threadId}>{t("companion.newChat")}</SelectItem> : null}
{threads.map(thread => <SelectItem key={thread.threadId} value={thread.threadId}>{thread.message.slice(0, 40)}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Button variant="outline" disabled={busy} onClick={() => { setThreadId(crypto.randomUUID()); setMessage(""); }}>{t("companion.newChat")}</Button>
</div> : null}
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
{!loading && !threadTurns.length ? <div className="space-y-3 py-8 text-center">
<MessageCircle aria-hidden="true" className="mx-auto h-9 w-9 text-emerald-600" />
<h2 className="text-sm font-semibold text-slate-900">{t(compact ? "aiAssistant.modes.emptyTitle" : "companion.emptyTitle")}</h2>
<p className="mx-auto max-w-md text-sm leading-relaxed text-slate-500">{t(compact ? "aiAssistant.modes.emptyHint" : "companion.intro")}</p>
<h2 className="text-sm font-semibold text-slate-900">{t("aiAssistant.modes.emptyTitle")}</h2>
<p className="mx-auto max-w-md text-sm leading-relaxed text-slate-500">{t("aiAssistant.modes.emptyHint")}</p>
{previousThread ? <div className="space-y-2">
<p className="mx-auto max-w-md truncate text-xs text-slate-400">{previousThread.message}</p>
<Button type="button" variant="outline" disabled={busy} onClick={() => setThreadId(previousThread.threadId)}>
@@ -234,18 +205,7 @@ export function CompanionChat({
</article>)}
</div>
<form onSubmit={send} className="space-y-2 border-t pt-3">
{!compact ? <div className="flex flex-wrap gap-4 text-sm">
<label className="flex items-center gap-2"><Checkbox checked={useMemory} disabled={busy} onCheckedChange={checked => void perform(async () => {
const { settings } = await api.getCompanionDiscoverySettings();
await api.saveCompanionDiscoverySettings({ enabled: settings.enabled, version: settings.version, useMemory: checked === true });
})} />{t("companion.useMemory")}</label>
<label className="flex items-center gap-2"><Checkbox checked={allowNotes} disabled={busy} onCheckedChange={checked => setAllowNotes(checked === true)} />{t("companion.allowNotes")}</label>
</div> : <p className="text-xs leading-relaxed text-slate-500">{t("aiAssistant.modes.askHint")}</p>}
{!compact ? <details className="text-xs leading-relaxed text-slate-500">
<summary className="cursor-pointer rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500">{t("companion.privacyTitle")}</summary>
<p className="mt-2">{t("companion.privacy")}</p>
<p className="mt-2">{t("companion.contextHelp")}</p>
</details> : null}
<p className="text-xs leading-relaxed text-slate-500">{t("aiAssistant.modes.askHint")}</p>
<label className="sr-only" htmlFor="companion-message">{t("companion.message")}</label>
<textarea
id="companion-message"
@@ -273,7 +233,7 @@ export function CompanionChat({
{running ? <Button type="button" variant="outline" onClick={() => void stop(running.id)}>{t("companion.stop")}</Button> : null}
<Button type="submit" disabled={busy || loading || Boolean(running) || !message.trim()}>
{busy ? t("common.processing") : t("companion.send")}
{compact && !busy ? <kbd aria-hidden="true" className="ml-1 rounded bg-slate-100 px-1 py-0.5 text-[10px] font-medium leading-none text-slate-400">↵</kbd> : null}
{!busy ? <kbd aria-hidden="true" className="ml-1 rounded bg-slate-100 px-1 py-0.5 text-[10px] font-medium leading-none text-slate-400">↵</kbd> : null}
</Button>
</div>
</form>
@@ -1,99 +0,0 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
import { I18nextProvider } from "react-i18next";
import { zhCN } from "../../../../packages/shared/src/i18n/zh-CN.ts";
import { enUS } from "../../../../packages/shared/src/i18n/en-US.ts";
import { TooltipProvider } from "./ui/tooltip.tsx";
import Hub from "./CompanionDiscoveryHub.tsx";
import { CompanionDiscoverySettingsCard } from "./settings/CompanionDiscoverySettingsCard.tsx";
import { discoverySettingsKey, discoveryFeedKey } from "../hooks/useCompanionDiscovery.ts";
async function render(enabled, locale = "zh-CN", card = false, settings = {}, items = [], aiReady = true) {
const client = new QueryClient({ defaultOptions: { queries: { gcTime: Infinity } } });
client.setQueryData(discoverySettingsKey("test"), { enabled, version: 0, lastCheckAt: null, lastStatus: "quiet", ...settings });
client.setQueryData(discoveryFeedKey("test"), items);
client.setQueryData(["ai-settings", locale], { defaultModelId: aiReady ? "default" : null,
providers: aiReady ? [{ isEnabled: true, models: [{ id: "default" }] }] : [], encryptionConfigured: true });
const i18n = createInstance(); await i18n.init({ lng: locale, resources: { "zh-CN": { translation: zhCN }, "en-US": { translation: enUS } } });
const component = card ? createElement(CompanionDiscoverySettingsCard, { scope: "test", onOpenCompanion() {}, onOpenAiSettings() {} })
: createElement(Hub, { scope: "test", onOpenNote() {}, async onNotesChanged() {}, onOpenSettings() {} });
const result = renderToStaticMarkup(createElement(I18nextProvider, { i18n }, createElement(QueryClientProvider, { client }, createElement(TooltipProvider, {}, component))));
client.clear(); return result;
}
describe("quiet discovery UI", () => {
test("disabled hides the Paw mode entry and panel", async () => {
const html = await render(false);
expect(html).toBe("");
expect(html).not.toContain("lucide-paw-print");
});
test("enabled shows only a quiet paw, not an automatically opened dialog", async () => {
const html = await render(true);
expect(html).toContain('aria-label="来自猫爪的发现"');
expect(html).toContain("lucide-paw-print");
expect(html).not.toContain("lucide-bell");
expect(html).not.toContain("fixed bottom-24");
expect(html).not.toContain('role="dialog"'); expect(html).not.toContain("有新发现");
});
test("Paw mode explains benefits and offers separate learning and memory controls", async () => {
const html = await render(false, "zh-CN", true);
expect(html).toContain("开启后,EdgeEver 会像猫爪轻推纸片一样,帮你归拢零散碎片、将新想法沉淀到已有笔记、挑出过往的相关灵感。");
expect(html).toContain("用得越久,猫爪越懂你。");
expect(html).toContain("猫爪模式");
expect(html).toContain("猫爪状态"); expect(html).toContain("未运行");
expect(html).toContain("尚未检查"); expect(html).toContain("启用后才会检查");
expect(html).toContain("从操作中学习"); expect(html).toContain("使用已保存的记忆");
expect(html).toContain('id="paw-learningEnabled"'); expect(html).toContain('id="paw-useMemory"');
expect(html).toContain('role="switch"'); expect(html).not.toContain('disabled=""');
expect(html).toContain('aria-checked="false"');
expect(html).not.toContain('role="checkbox"'); expect(html).not.toContain("<fieldset");
expect(html).toContain("对话与个人记忆");
});
test("enabled settings show the real check state and latest generated discovery", async () => {
const html = await render(true, "zh-CN", true,
{ lastStatus: "ready", lastCheckAt: "2026-09-04T06:30:00.000Z" },
[{ id: "discovery", kind: "insight", title: "两个产品想法可以互相补充", body: "早期记录解释了新方案的取舍。",
sources: [], action: null, seen: false, createdAt: "2026-09-04T06:30:00.000Z" }]);
expect(html).toContain("最近检查完成"); expect(html).toContain("最近检查"); expect(html).toContain("下次检查");
expect(html).toContain("最近发现"); expect(html).toContain("两个产品想法可以互相补充");
expect(html).toContain("早期记录解释了新方案的取舍。"); expect(html).not.toContain("个性化偏好");
});
test("shows the AI model prerequisite before Paw mode can be enabled", async () => {
const html = await render(false, "zh-CN", true, {}, [], false);
expect(html).toContain("开启猫爪模式前,请先配置并启用默认 AI 模型。");
expect(html).toContain("前往 AI 集成");
});
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.");
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");
expect(html).not.toContain("companion.discovery."); expect(html).toContain("Paw mode");
});
test("checks are event-driven, sync-gated and cancel on cleanup", () => {
const source = readFileSync(new URL("./CompanionDiscoveryHub.tsx", import.meta.url), "utf8");
expect(source).not.toContain("setInterval"); expect(source).not.toContain("new Notification");
expect(source).toContain("const DISCOVERY_IDLE_DELAY_MS = 3 * 60_000");
expect(source).toContain("attemptedSinceWorkspaceChange");
expect(source).toContain("scheduleAfterWorkspaceChange");
expect(source).toContain('document.visibilityState !== "visible"'); expect(source).toContain("await assertCompanionChangesSynced(scope)");
expect(source).toContain("stop.abort()"); expect(source).toContain("IntersectionObserver");
expect(source).not.toContain("companion.discovery.kind.");
expect(source).not.toContain("companion.discovery.types.");
expect(source).not.toContain("companion.discovery.panelDescription");
expect(source).not.toContain("companion.discovery.tagline");
});
test("composes the discovery panel from shared shadcn UI primitives", () => {
const source = readFileSync(new URL("./CompanionDiscoveryHub.tsx", import.meta.url), "utf8");
expect(source).toContain('from "@/components/ui/card"');
expect(source).toContain('from "@/components/ui/badge"');
expect(source).toContain('from "@/components/ui/alert"');
expect(source).toContain("<Card");
expect(source).toContain("<Badge");
expect(source).toContain('<Alert variant="destructive"');
});
});
@@ -1,282 +0,0 @@
import { companionMemoryText } from "@/lib/companion-memory";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ArrowUpRight,
FileText,
PawPrint,
Settings,
} from "lucide-react";
import type { CompanionAction, CompanionDiscoveryItem } from "@edgeever/shared";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { api, ApiRequestError } from "@/lib/api";
import { assertCompanionChangesSynced } from "@/lib/companion-actions";
import { discoveryFeedKey, discoverySettingsKey, useCompanionDiscoverySettings } from "@/hooks/useCompanionDiscovery";
import { CompanionActionCard } from "./CompanionActionCard";
const DISCOVERY_IDLE_DELAY_MS = 3 * 60_000;
export default function CompanionDiscoveryHub({ scope, onOpenNote, onNotesChanged, onOpenSettings }: {
scope: string; onOpenNote: (id: string, notebookId: string) => void; onNotesChanged: () => Promise<void>; onOpenSettings: () => void;
}) {
const { t, i18n } = useTranslation();
const client = useQueryClient();
const settings = useCompanionDiscoverySettings(scope);
const enabled = settings.data?.enabled === true;
const feed = useQuery({ queryKey: discoveryFeedKey(scope), queryFn: async () => (await api.listCompanionDiscoveries()).items,
enabled, staleTime: 60_000, retry: false });
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const locked = useRef(false);
const items = feed.data ?? [];
useEffect(() => {
if (!enabled) return;
let timer: ReturnType<typeof setTimeout> | undefined;
let running = false;
let attemptedSinceWorkspaceChange = false;
const stop = new AbortController();
const check = async () => {
if (running || attemptedSinceWorkspaceChange || stop.signal.aborted || document.visibilityState !== "visible" || !navigator.onLine) return;
running = true;
try {
await assertCompanionChangesSynced(scope);
if (stop.signal.aborted) return;
const result = await api.checkCompanionDiscoveries(i18n.resolvedLanguage ?? "en-US", stop.signal);
if (!stop.signal.aborted) {
attemptedSinceWorkspaceChange = true;
client.setQueryData(discoveryFeedKey(scope), result.items);
}
} catch { /* Quiet by design; check status is visible inside the panel. */ }
finally {
running = false;
if (!stop.signal.aborted) void client.invalidateQueries({ queryKey: discoverySettingsKey(scope) });
}
};
const schedule = () => {
clearTimeout(timer);
if (!attemptedSinceWorkspaceChange && document.visibilityState === "visible" && navigator.onLine) {
timer = setTimeout(() => void check(), DISCOVERY_IDLE_DELAY_MS);
}
};
const activityEvents = ["keydown", "pointerdown", "input", "online"];
const workspaceChangeEvents = ["edgeever:sync-queue-changed", "edgeever:memo-detail-refreshed", "edgeever:companion-memory-changed"];
const scheduleAfterWorkspaceChange = () => { attemptedSinceWorkspaceChange = false; schedule(); };
activityEvents.forEach(name => window.addEventListener(name, schedule));
workspaceChangeEvents.forEach(name => window.addEventListener(name, scheduleAfterWorkspaceChange));
document.addEventListener("visibilitychange", schedule);
schedule();
return () => {
clearTimeout(timer); stop.abort();
activityEvents.forEach(name => window.removeEventListener(name, schedule));
workspaceChangeEvents.forEach(name => window.removeEventListener(name, scheduleAfterWorkspaceChange));
document.removeEventListener("visibilitychange", schedule);
};
}, [enabled, settings.data?.version, scope, i18n.resolvedLanguage, client]);
const perform = async (work: () => Promise<void>) => {
if (locked.current) return;
locked.current = true; setBusy(true); setError(null);
try { await work(); }
catch (cause) {
setError(t(cause instanceof ApiRequestError && cause.code === "companion_action_unsynced"
? "companion.actions.unsynced" : "companion.discovery.actionFailed"));
} finally {
await feed.refetch(); locked.current = false; setBusy(false);
}
};
const apply = (action: CompanionAction) => void perform(async () => {
await assertCompanionChangesSynced(scope);
const result = await api.applyCompanionAction(action.id);
// Persisted receipts remain visible even if local refresh fails.
client.setQueryData<CompanionDiscoveryItem[]>(discoveryFeedKey(scope), current => current?.map(item => item.action?.id === action.id ? { ...item, action: result.action } : item));
await onNotesChanged();
});
const dismiss = (item: CompanionDiscoveryItem) => void perform(async () => { await api.acknowledgeCompanionDiscovery(item.id, true); });
const openNote = (id: string, notebookId: string) => { setOpen(false); onOpenNote(id, notebookId); };
if (!enabled) return null;
return <>
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="relative h-8 w-8 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-950 focus-visible:ring-2 focus-visible:ring-emerald-500/70"
aria-label={t("companion.discovery.title")} onClick={() => { setOpen(true); void feed.refetch(); }}>
<PawPrint className="h-5 w-5" strokeWidth={2.25} aria-hidden="true" />
{items.some(item => !item.seen && item.action?.status !== "applied") ? <span aria-label={t("companion.discovery.unread")} className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-emerald-500 ring-2 ring-white " /> : null}
</Button>
</TooltipTrigger>
<TooltipContent>{t("companion.discovery.title")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="grid max-h-[min(760px,calc(100dvh-2rem))] w-[calc(100%-2rem)] max-w-xl grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-slate-100 px-5 py-4 text-left ">
<div className="flex items-center justify-between pr-8">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600 ring-1 ring-emerald-500/20 ">
<PawPrint className="h-4 w-4" strokeWidth={2.5} />
</div>
<div className="flex items-center gap-2">
<DialogTitle className="text-base font-semibold text-slate-900 ">
{t("companion.discovery.title")}
</DialogTitle>
{items.length > 0 ? (
<Badge variant="outline" className="border-emerald-600/20 bg-emerald-50 px-2 font-medium text-emerald-700 ">
{items.length}
</Badge>
) : null}
</div>
</div>
</div>
</DialogHeader>
<div className="min-h-0 space-y-3.5 overflow-y-auto px-5 py-4">
{error ? (
<Alert variant="destructive" className="p-2.5">
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
{feed.isError ? (
<Alert variant="destructive" className="p-2.5">
<AlertDescription className="text-xs">{t("companion.discovery.loadFailed")}</AlertDescription>
</Alert>
) : null}
{!feed.isPending && !feed.isError && !items.length ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-50 text-slate-400 ring-1 ring-slate-200 ">
<PawPrint className="h-6 w-6 stroke-[1.5]" />
</div>
<p className="max-w-xs text-sm font-medium text-slate-600 ">
{t("companion.discovery.empty")}
</p>
</div>
) : null}
{items.map(item => <DiscoveryCard key={item.id} item={item} busy={busy} open={open}
onFeedback={() => void perform(async () => { await api.rememberCompanionDiscoveryFeedback(item.id); window.dispatchEvent(new Event("edgeever:companion-memory-changed")); })}
onApply={apply} onDismiss={() => dismiss(item)} onOpenNote={openNote}
onSeen={() => { void api.acknowledgeCompanionDiscovery(item.id).then(() => client.setQueryData<CompanionDiscoveryItem[]>(discoveryFeedKey(scope),
current => current?.map(entry => entry.id === item.id ? { ...entry, seen: true } : entry))).catch(() => {}); }} />)}
</div>
<div className="flex justify-end border-t border-slate-100 bg-slate-50/50 px-5 py-2.5 ">
<Button
size="sm"
variant="ghost"
className="h-7 shrink-0 gap-1.5 px-2 text-xs text-slate-600 hover:text-slate-900 "
onClick={() => { setOpen(false); onOpenSettings(); }}
>
<Settings className="h-3.5 w-3.5" />
{t("companion.settings")}
</Button>
</div>
</DialogContent>
</Dialog>
</>;
}
function DiscoveryCard({ item, busy, open, onApply, onDismiss, onOpenNote, onSeen, onFeedback }: {
item: CompanionDiscoveryItem; busy: boolean; open: boolean; onApply: (action: CompanionAction) => void;
onFeedback: () => void; onDismiss: () => void; onOpenNote: (id: string, notebookId: string) => void; onSeen: () => void;
}) {
const { t, i18n } = useTranslation();
const ref = useRef<HTMLElement>(null);
const seenCallback = useRef(onSeen); seenCallback.current = onSeen;
useEffect(() => {
if (!open || item.seen || !ref.current) return;
const observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) { seenCallback.current(); observer.disconnect(); }
}, { threshold: 0.1 });
observer.observe(ref.current);
return () => observer.disconnect();
}, [open, item.seen]);
const formattedDate = item.createdAt
? new Date(item.createdAt).toLocaleDateString(i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en-US", {
month: "numeric",
day: "numeric",
})
: null;
return (
<article ref={ref}>
<Card className="group relative flex flex-col gap-3 rounded-xl border border-slate-200/90 bg-card p-4 shadow-xs transition-all hover:border-slate-300 hover:shadow-sm ">
<CardHeader className="flex-row items-start justify-between gap-3 p-0">
<div className="min-w-0">
<CardTitle className="line-clamp-1 break-words text-sm leading-snug text-slate-900 ">
{item.title}
</CardTitle>
</div>
{formattedDate ? (
<time className="shrink-0 pt-0.5 font-mono text-[11px] text-slate-400 ">
{formattedDate}
</time>
) : null}
</CardHeader>
<CardContent className="space-y-3 p-0">
{item.action?.status === "pending" ? <details className="text-xs text-slate-500">
<summary className="cursor-pointer">{t("companion.learning.feedback")}</summary>
<p className="my-2">{t("companion.learning.feedbackHelp")}</p>
<Button size="sm" variant="outline" disabled={busy} onClick={onFeedback}>{t("companion.learning.feedback")}</Button>
</details> : null}
{item.memories?.length ? <details className="text-xs text-slate-500">
<summary className="cursor-pointer">{t("companion.learning.basedOn")}</summary>
<ul className="mt-2 space-y-1">{item.memories.map(memory => <li key={memory.id}>{companionMemoryText(memory, t)}</li>)}</ul>
</details> : null}
{!item.action ? (
<p className="line-clamp-4 whitespace-pre-wrap break-words text-xs leading-relaxed text-slate-600 sm:text-sm ">
{item.body}
</p>
) : null}
{item.kind === "append" ? (
<p className="rounded-lg border border-emerald-100 bg-emerald-50/50 p-2.5 text-xs leading-relaxed text-emerald-700 ">
{t("companion.discovery.appendHelp")}
</p>
) : null}
{item.action ? (
<CompanionActionCard
action={item.action}
busy={busy}
onApply={onApply}
onDismiss={onDismiss}
onOpenNote={onOpenNote}
/>
) : item.sources.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{item.sources.map(source => (
<Button
key={source.id}
size="sm"
variant="outline"
onClick={() => onOpenNote(source.id, source.notebookId)}
className="group/btn h-7 max-w-full gap-1.5 bg-slate-50/70 px-2.5 text-xs text-slate-700 hover:border-emerald-400 hover:bg-emerald-50/50 hover:text-emerald-700 "
>
<FileText className="h-3 w-3 shrink-0 text-slate-400 group-hover/btn:text-emerald-600 " />
<span className="max-w-[260px] truncate">{source.title || t("common.untitledMemo")}</span>
<ArrowUpRight className="h-3 w-3 shrink-0 opacity-40 group-hover/btn:opacity-100" />
</Button>
))}
</div>
) : null}
</CardContent>
{!item.action ? (
<CardFooter className="justify-end border-t border-slate-100 p-0 pt-2.5 ">
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={onDismiss}
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-700 "
>
{t("companion.discovery.dismiss")}
</Button>
</CardFooter>
) : null}
</Card>
</article>
);
}
@@ -1,60 +0,0 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createInstance } from "i18next";
import { I18nextProvider } from "react-i18next";
import { zhCN } from "../../../../packages/shared/src/i18n/zh-CN.ts";
import { enUS } from "../../../../packages/shared/src/i18n/en-US.ts";
import CompanionPane from "./CompanionPane.tsx";
const renderPane = async (available, locale = "zh-CN") => {
const i18n = createInstance();
await i18n.init({ lng: locale, resources: { "zh-CN": { translation: zhCN }, "en-US": { translation: enUS } } });
return renderToStaticMarkup(createElement(I18nextProvider, { i18n }, createElement(CompanionPane, {
available, onBack() {}, onOpenSettings() {},
})));
};
describe("companion workspace", () => {
test("renders a standalone page with chat, memory controls, and an explicit return to notes", async () => {
const markup = await renderPane(true);
expect(markup).toContain('aria-labelledby="companion-title"');
expect(markup).toContain("返回笔记");
expect(markup).toContain("她记得的事");
expect(markup).toContain('id="companion-message"');
expect(markup).toContain('aria-pressed="true"');
expect(markup).toContain("默认模型服务商");
expect(markup).not.toContain('role="dialog"');
expect(markup).not.toContain('aria-modal="true"');
});
test("explains unavailable personal data without rendering the chat workspace", async () => {
const markup = await renderPane(false);
expect(markup).toContain("与 EdgeEver 聊聊");
expect(markup).toContain("暂不读取或保存个人记忆");
expect(markup).not.toContain('id="companion-message"');
expect(markup).not.toContain("她记得的事");
});
test("provides the same availability and navigation guidance in English", async () => {
const markup = await renderPane(false, "en-US");
expect(markup).toContain("Chat with EdgeEver");
expect(markup).toContain("Back to notes");
expect(markup).toContain("Personal memories are not read or saved");
expect(markup).not.toContain("companion.");
});
test("is routed and lazy-loaded independently of the prompt library", () => {
const workspace = readFileSync(new URL("./WorkspaceApp.tsx", import.meta.url), "utf8");
const routes = readFileSync(new URL("../app/App.tsx", import.meta.url), "utf8");
const prompts = readFileSync(new URL("./AiPromptsPane.tsx", import.meta.url), "utf8");
expect(workspace).toContain('const CompanionPane = lazy(() => import("./CompanionPane"))');
expect(workspace).toContain('available={authRequired && Boolean(user) && !demoMode}');
expect(workspace).toContain('<CompanionPane key={localDataScope}');
expect(workspace).toContain('!route.isCompanion && rightView !== "companion"');
expect(workspace).toContain('if (returningFromCompanion) setActivePane("memos")');
expect(routes).toContain('<Route path="/companion" element={<AuthenticatedWorkspace />} />');
expect(prompts).not.toMatch(/companion/i);
});
});
-158
View File
@@ -1,158 +0,0 @@
import { companionMemoryText } from "@/lib/companion-memory";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { ChevronLeft, MessageCircle } from "lucide-react";
import type { CompanionMemory } from "@edgeever/shared";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs";
import { api, ApiRequestError } from "@/lib/api";
import { CompanionChat } from "./CompanionChat";
type CompanionActionHandlers = {
beforeApply: () => Promise<void>;
onNotesChanged: () => Promise<void>;
onOpenNote: (id: string, notebookId: string) => void;
};
export default function CompanionPane({ available, onBack, onOpenSettings, ...actionHandlers }: {
available: boolean;
onBack: () => void;
onOpenSettings: () => void;
} & CompanionActionHandlers) {
const { t } = useTranslation();
return <section className="flex h-full min-h-0 flex-col bg-card" aria-labelledby="companion-title">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-4 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))] lg:px-8">
<div className="flex min-w-0 items-center gap-3">
<Button variant="ghost" size="sm" onClick={onBack}>
<ChevronLeft className="mr-1 h-4 w-4" />{t("companion.backToNotes")}
</Button>
<h1 id="companion-title" className="text-sm font-semibold text-slate-900">{t("companion.title")}</h1>
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700">{t("companion.preview")}</span>
</div>
<Button variant="ghost" size="sm" onClick={onOpenSettings}>{t("companion.settings")}</Button>
</header>
{available ? <CompanionWorkspace {...actionHandlers} /> : <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 overflow-y-auto p-6 text-center">
<MessageCircle aria-hidden="true" className="h-10 w-10 text-emerald-600" />
<h2 className="text-lg font-semibold text-slate-900">{t("companion.emptyTitle")}</h2>
<p className="max-w-lg text-sm leading-relaxed text-slate-500">{t("companion.intro")}</p>
<p role="status" className="max-w-lg rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm leading-relaxed text-slate-600">{t("companion.unavailableHelp")}</p>
</div>}
</section>;
}
function CompanionWorkspace({ beforeApply, onNotesChanged, onOpenNote }: CompanionActionHandlers) {
const { t, i18n } = useTranslation();
const [memories, setMemories] = useState<CompanionMemory[]>([]);
const [memoryContent, setMemoryContent] = useState("");
const [editing, setEditing] = useState<CompanionMemory | null>(null);
const [tab, setTab] = useState<"chat" | "memories">("chat");
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [confirmation, setConfirmation] = useState<CompanionMemory | "history" | null>(null);
const alive = useRef(true);
const locked = useRef(false);
const fileInput = useRef<HTMLInputElement>(null);
const explainError = (cause: unknown) => {
const code = cause instanceof ApiRequestError ? cause.code : "";
if (code === "ai_not_configured") return t("companion.configureModel");
if (code === "companion_memory_conflict") return t("companion.conflict");
if (code === "companion_history_full") return t("companion.historyFull");
if (cause instanceof ApiRequestError && cause.status === 403) return t("companion.unavailable");
return t("companion.failed");
};
const reload = async () => {
const memoryResult = await api.listCompanionMemories();
if (!alive.current) return;
setMemories(memoryResult.memories);
};
useEffect(() => {
alive.current = true;
void reload().catch(cause => { if (alive.current) setError(explainError(cause)); })
.finally(() => { if (alive.current) setLoading(false); });
return () => { alive.current = false; };
// This workspace owns ephemeral account-scoped state, discarded on unmount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const perform = async (work: () => Promise<unknown>) => {
if (locked.current) return;
locked.current = true;
setBusy(true); setError(null);
try { await work(); await reload(); window.dispatchEvent(new Event("edgeever:companion-memory-changed")); }
catch (cause) { if (alive.current) setError(explainError(cause)); }
finally { locked.current = false; if (alive.current) setBusy(false); }
};
const saveMemory = (event: FormEvent) => {
event.preventDefault();
void perform(async () => {
if (editing) await api.updateCompanionMemory(editing, memoryContent.trim());
else await api.saveCompanionMemory(memoryContent.trim());
setEditing(null); setMemoryContent("");
});
};
const exportData = () => void perform(async () => {
const data = await api.exportCompanion();
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }));
const link = document.createElement("a");
link.href = url; link.download = "edgeever-companion.json"; link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
return <div className="mx-auto flex min-h-0 w-full max-w-4xl flex-1 flex-col gap-3 overflow-hidden px-4 py-4 lg:px-8 lg:py-6">
<div className="flex flex-wrap gap-2">
<Button aria-pressed={tab === "chat"} variant={tab === "chat" ? "solid" : "outline"} onClick={() => setTab("chat")}>{t("companion.chat")}</Button>
<Button aria-pressed={tab === "memories"} variant={tab === "memories" ? "solid" : "outline"} onClick={() => setTab("memories")}>{t("companion.memories")} ({memories.length}/50)</Button>
<Button variant="ghost" disabled={busy || loading} onClick={() => void perform(reload)}>{t("common.refresh")}</Button>
</div>
{error ? <p role="alert" className="text-sm text-rose-700">{error}</p> : null}
{tab === "chat" ? <CompanionChat available beforeApply={beforeApply} onNotesChanged={onNotesChanged} onOpenNote={onOpenNote} variant="page" allowWrites /> : <div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
<p className="text-sm text-slate-500">{t("companion.memoryHelp")}</p>
<form className="space-y-2" onSubmit={saveMemory}>
<label className="block text-sm" htmlFor="companion-memory">{editing ? t("companion.correct") : t("companion.addMemory")}</label>
<Input id="companion-memory" value={memoryContent} maxLength={500} required disabled={busy} onChange={e => setMemoryContent(e.target.value)} />
<div className="flex gap-2">
<Button type="submit" disabled={busy || loading || !memoryContent.trim()}>{t("common.save")}</Button>
{editing ? <Button variant="ghost" onClick={() => { setEditing(null); setMemoryContent(""); }}>{t("common.cancel")}</Button> : null}
</div>
</form>
{memories.map(memory => <article key={memory.id} className="space-y-2 rounded-lg border p-3 text-sm">
<p className="whitespace-pre-wrap break-words">{companionMemoryText(memory, t)}</p>
{memory.scopeNotebookName ? <p className="text-xs text-slate-500">{memory.scopeNotebookName}</p> : null}
<p className="text-xs text-slate-500">{memory.kind === "inferred" ? t(`companion.learning.${memory.state ?? "candidate"}`) : t(memory.sourceTurnId ? "companion.fromMessage" : "companion.fromManual")}</p>
{memory.evidence?.length ? <details className="text-xs text-slate-500">
<summary className="cursor-pointer">{t("companion.learning.evidence")}</summary>
<ul className="mt-2 space-y-1">{memory.evidence.map((entry, index) => <li key={`${entry.memoId}-${index}`}>
{new Date(entry.createdAt).toLocaleDateString(i18n.resolvedLanguage)} · <Button variant="ghost" size="sm" className="h-auto p-0 text-xs underline"
disabled={!entry.notebookId} onClick={() => entry.notebookId && onOpenNote(entry.memoId, entry.notebookId)}>{entry.title || t("common.untitledMemo")}</Button>
</li>)}</ul>
</details> : null}
<Button size="sm" variant="ghost" disabled={busy} onClick={() => { setEditing(memory); setMemoryContent(companionMemoryText(memory, t)); }}>{t("companion.correct")}</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => setConfirmation(memory)}>{t("companion.forget")}</Button>
</article>)}
<p className="text-xs text-slate-500">{t("companion.backupHelp")}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" disabled={busy || loading} onClick={exportData}>{t("companion.export")}</Button>
<Button variant="outline" disabled={busy || loading} onClick={() => fileInput.current?.click()}>{t("companion.import")}</Button>
<input ref={fileInput} type="file" accept="application/json,.json" className="hidden" aria-label={t("companion.import")} onChange={event => {
const file = event.target.files?.[0]; event.target.value = "";
if (file) void perform(async () => {
if (file.size > 10 * 1048576) throw new Error("File too large");
const data = JSON.parse(await file.text()) as { version: number; memories: CompanionMemory[]; controls?: { useMemory: boolean; learningEnabled: boolean } };
if (![1, 2].includes(data.version) || !Array.isArray(data.memories)) throw new Error("Invalid backup");
await api.importCompanionMemories(data.memories.map(memory => ({ content: memory.content, kind: memory.kind })), data.controls);
});
}} />
<Button variant="outline" disabled={busy || loading} onClick={() => setConfirmation("history")}>{t("companion.clearHistory")}</Button>
</div>
</div>}
{confirmation ? <AppConfirmDialog title={t(confirmation === "history" ? "companion.clearHistory" : "companion.forget")}
description={t(confirmation === "history" ? "companion.clearHelp" : "companion.forgetHelp")} confirmLabel={t("common.delete")} isWorking={busy}
onCancel={() => setConfirmation(null)} onConfirm={() => void perform(async () => {
if (confirmation === "history") await api.clearCompanionHistory();
else await api.forgetCompanionMemory(confirmation);
setConfirmation(null); setEditing(null); setMemoryContent("");
})} /> : null}
</div>;
}
@@ -164,7 +164,6 @@ type DiagramEditorPaneProps = {
onSaveAsTemplate: (memo: MemoDetail, name: string) => Promise<void>;
onToggleDesktopFocusMode: () => void;
onOpenExecutionCenter: () => void;
companionDiscoveryHub?: ReactNode;
};
type NodeData = { label: string; shape: DiagramNodeShape; parentId?: string; resourceIcon?: ArchitectureResourceIcon };
@@ -1343,7 +1342,6 @@ export const DiagramEditorPane = ({
onSaveAsTemplate,
onToggleDesktopFocusMode,
onOpenExecutionCenter,
companionDiscoveryHub,
}: DiagramEditorPaneProps) => {
const { t } = useTranslation();
const { resolvedTheme } = useAppearanceTheme();
@@ -2777,7 +2775,6 @@ export const DiagramEditorPane = ({
</Button>
)}
<MemoEditorHeaderActions
companionDiscoveryHub={companionDiscoveryHub}
moreMenuClassName="w-48"
onOpenExecutionCenter={onOpenExecutionCenter}
onSearch={openSearch}
-3
View File
@@ -310,7 +310,6 @@ type EditorPaneProps = {
pluginHost: EdgeEverPluginHost;
pluginNavigationRequest?: { id: number; noteId: string; search: string } | null;
onOpenExecutionCenter: () => void;
companionDiscoveryHub?: ReactNode;
};
type RichEditorPaneProps = EditorPaneProps & {
@@ -388,7 +387,6 @@ const RichEditorPane = ({
pluginHost,
pluginNavigationRequest,
onOpenExecutionCenter,
companionDiscoveryHub,
onRequestMobileNativeEdit,
}: RichEditorPaneProps) => {
const { t, i18n } = useTranslation();
@@ -3424,7 +3422,6 @@ const RichEditorPane = ({
</Button>
)}
<MemoEditorHeaderActions
companionDiscoveryHub={companionDiscoveryHub}
moreButtonClassName={cn(!mobileEditingActive && !readOnly && "hidden sm:inline-flex")}
moreMenuClassName="w-44 rounded-md"
onOpenExecutionCenter={onOpenExecutionCenter}
@@ -16,7 +16,6 @@ describe("shared memo editor header actions", () => {
expect(actionsSource).toContain("<Search");
expect(actionsSource).toContain("<GitHubRepositoryLink");
expect(actionsSource).toContain("<SystemInfoDialog");
expect(actionsSource).toContain("{companionDiscoveryHub}");
expect(actionsSource).toContain("<ExecutionCenterButton");
expect(actionsSource).toContain("<ThemeToggle />");
expect(actionsSource).toContain("<MoreHorizontal");
@@ -17,7 +17,6 @@ import { cn } from "@/lib/utils";
import { IconTooltip } from "./editor/EditorPaneChrome";
export const MemoEditorHeaderActions = ({
companionDiscoveryHub,
moreButtonClassName,
moreMenuClassName,
moreMenuItems,
@@ -27,7 +26,6 @@ export const MemoEditorHeaderActions = ({
textNoteActions,
textNoteMenuItems,
}: {
companionDiscoveryHub?: ReactNode;
moreButtonClassName?: string;
moreMenuClassName?: string;
moreMenuItems: ReactNode;
@@ -72,7 +70,6 @@ export const MemoEditorHeaderActions = ({
{deployedUpdateUnseen ? <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-emerald-500 ring-2 ring-white" /> : null}
</Button>
</IconTooltip>
{companionDiscoveryHub}
<ExecutionCenterButton className="h-8 w-8" onClick={onOpenExecutionCenter} />
<ThemeToggle />
<DropdownMenu>
@@ -4,7 +4,7 @@ import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./NotebookPane.tsx", import.meta.url), "utf8");
test("keeps proactive AI out of primary navigation", () => {
const primaryNav = source.split('aria-label={t("companion.primaryNavigation")}>')[1]?.split("</nav>")[0];
const primaryNav = source.split('aria-label={t("notebookPane.entries")}>')[1]?.split("</nav>")[0];
expect(primaryNav).toContain('label={t("notebookPane.allMemos")}');
expect(primaryNav).not.toContain('label={t("companion.navTitle")}');
expect(primaryNav).not.toContain("onClick={onOpenCompanion}");
+6 -8
View File
@@ -473,7 +473,6 @@ export const NotebookPane = ({
onOpenTags,
onOpenAssets,
onOpenTemplates,
companionActive,
pluginHost,
onOpenPluginManager,
onOpenTrash,
@@ -512,7 +511,6 @@ export const NotebookPane = ({
onOpenTags: () => void;
onOpenAssets: () => void;
onOpenTemplates: () => void;
companionActive: boolean;
pluginHost: EdgeEverPluginHost;
onOpenPluginManager: () => void;
onOpenTrash: () => void;
@@ -738,9 +736,9 @@ export const NotebookPane = ({
</button>
)}
<nav className="mb-1 space-y-1" aria-label={t("companion.primaryNavigation")}>
<nav className="mb-1 space-y-1" aria-label={t("notebookPane.entries")}>
<SidebarNavButton
active={!companionActive && view === "notebook" && selectedNotebookId === null}
active={view === "notebook" && selectedNotebookId === null}
icon={<LayoutList className="h-4 w-4" />}
label={t("notebookPane.allMemos")}
onClick={onBackToList}
@@ -810,7 +808,7 @@ export const NotebookPane = ({
key={node.id}
node={node}
depth={0}
selectedNotebookId={companionActive ? null : selectedNotebookId}
selectedNotebookId={selectedNotebookId}
onSelect={onSelect}
onCreateNotebook={onCreateNotebook}
onRenameNotebook={onRenameNotebook}
@@ -868,15 +866,15 @@ export const NotebookPane = ({
</DropdownMenu>
</div>
<nav className="mt-3 flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={t("companion.primaryNavigation")}>
<nav className="mt-3 flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={t("notebookPane.entries")}>
<SidebarRailButton
active={!companionActive && view === "notebook" && selectedNotebookId === null}
active={view === "notebook" && selectedNotebookId === null}
icon={<LayoutList className="h-4 w-4" />}
label={t("notebookPane.allMemos")}
onClick={onBackToList}
/>
<SidebarRailButton
active={!companionActive && view === "notebook" && selectedNotebookId !== null}
active={view === "notebook" && selectedNotebookId !== null}
icon={<NotebookIcon className="h-4 w-4" />}
label={t("notebookPane.notebooks")}
onClick={onToggleCollapsed}
+2 -45
View File
@@ -4,7 +4,6 @@ import {
Database,
Info,
LayoutTemplate,
PawPrint,
Shield,
SlidersHorizontal,
Sparkles,
@@ -17,13 +16,7 @@ import { useTranslation } from "react-i18next";
import * as m from "motion/react-m";
import { SystemInfoDialog } from "@/components/SystemInfoDialog";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
SETTINGS_CARD_DESCRIPTION_CLASSNAME,
SETTINGS_CARD_HEADER_CLASSNAME,
SETTINGS_CARD_ICON_CLASSNAME,
SETTINGS_CARD_TITLE_CLASSNAME,
} from "./settings/settings-ui";
import type { EditorContentAlignment, ShortcutSettings } from "@/lib/app-helpers";
import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
import { cn } from "@/lib/utils";
@@ -43,7 +36,6 @@ import { AiModelCard } from "./settings/AiModelCard";
import { AiTagSuggestionPromptCard } from "./settings/AiTagSuggestionPromptCard";
import { ThemeToggle } from "./ThemeToggle";
import type { AuthUser } from "@edgeever/shared";
import { CompanionDiscoverySettingsCard } from "./settings/CompanionDiscoverySettingsCard";
import { contentEnterMotion } from "@/lib/motion";
import { useDeployedUpdateNotice } from "@/hooks/useDeployedUpdateNotice";
import { ExecutionCenterButton } from "@/components/execution/ExecutionCenterButton";
@@ -66,8 +58,6 @@ interface SettingsPaneProps {
user: AuthUser | null;
refreshWorkspaceAfterImport: () => Promise<void>;
onOpenExecutionCenter: () => void;
companionScope: string;
onOpenCompanion: () => void;
}
// Slate and brand color variables already switch values with the root theme.
@@ -77,7 +67,7 @@ const SettingsGroup = ({ children }: { children: ReactNode }) => (
</div>
);
type TabKey = "general" | "paw" | "users" | "data" | "ai" | "advanced" | "account";
type TabKey = "general" | "users" | "data" | "ai" | "advanced" | "account";
interface TabItem {
key: TabKey;
@@ -108,8 +98,6 @@ export const SettingsPane = ({
user,
refreshWorkspaceAfterImport,
onOpenExecutionCenter,
companionScope,
onOpenCompanion,
}: SettingsPaneProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabKey>("general");
@@ -159,16 +147,6 @@ export const SettingsPane = ({
},
]
: []),
{
key: "paw",
label: t("settings.tabs.paw"),
badge: "Beta",
icon: PawPrint,
colorClass: "text-emerald-700",
bgColorClass: "bg-emerald-50/80",
hoverColorClass: "hover:bg-emerald-50/40",
iconColorClass: "text-emerald-600",
},
{
key: "advanced",
label: t("settings.tabs.advanced"),
@@ -238,27 +216,6 @@ export const SettingsPane = ({
<ProductHuntLink className="hidden lg:flex" />
</SettingsGroup>
);
case "paw":
return (
<SettingsGroup>
{authRequired && user && !demoMode ? (
<CompanionDiscoverySettingsCard scope={companionScope} onOpenCompanion={onOpenCompanion}
onOpenAiSettings={() => setActiveTab("ai")} />
) : (
<Card className="shadow-none">
<CardHeader className={SETTINGS_CARD_HEADER_CLASSNAME}>
<CardTitle className={SETTINGS_CARD_TITLE_CLASSNAME}>
<PawPrint className={SETTINGS_CARD_ICON_CLASSNAME} />
{t("companion.discovery.settingsTitle")}
</CardTitle>
<CardDescription className={SETTINGS_CARD_DESCRIPTION_CLASSNAME}>
{t("companion.unavailableHelp")}
</CardDescription>
</CardHeader>
</Card>
)}
</SettingsGroup>
);
case "users":
return isOwner ? (
<SettingsGroup>
+16 -70
View File
@@ -124,7 +124,7 @@ import { clearRendererRecoveryRequired, isRendererRecoveryRequired } from "@/lib
import { EditorPaneErrorBoundary, EditorRecoveryPane } from "./EditorPaneErrorBoundary";
import { isMarkdownFile, readMarkdownFile } from "@/lib/markdown-file-import";
import { compressImageForUpload } from "@/lib/image-compression";
import { createScreenshotMemo, screenshotFileFromImportPayload } from "@/lib/screenshot-import";
import { createScreenshotMemo, screenshotFileFromImportPayload, screenshotImportDedupeKey, screenshotImportGate } from "@/lib/screenshot-import";
import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceUrl } from "@/lib/desktop-resources";
const EditorPane = lazy(() => import("./EditorPane").then((module) => ({ default: module.EditorPane })));
@@ -139,8 +139,6 @@ const EvernoteImportGuidePane = lazy(() =>
const TagsPane = lazy(() => import("./TagsPane").then((module) => ({ default: module.TagsPane })));
const TemplatesPane = lazy(() => import("./TemplatesPane").then((module) => ({ default: module.TemplatesPane })));
const AiPromptsPane = lazy(() => import("./AiPromptsPane").then((module) => ({ default: module.AiPromptsPane })));
const CompanionPane = lazy(() => import("./CompanionPane"));
const CompanionDiscoveryHub = lazy(() => import("./CompanionDiscoveryHub"));
const ExecutionCenterPane = lazy(() =>
import("./execution/ExecutionCenterPane").then((module) => ({ default: module.ExecutionCenterPane }))
);
@@ -189,7 +187,6 @@ export const WorkspaceApp = ({
navigatePlugins: navigateWorkspacePlugins,
navigateTemplates: navigateWorkspaceTemplates,
navigateAiPrompts: navigateWorkspaceAiPrompts,
navigateCompanion: navigateWorkspaceCompanion,
navigateExecutionCenter: navigateWorkspaceExecutionCenter,
} = useWorkspaceRoute();
const localDataScope = useMemo(
@@ -201,15 +198,13 @@ export const WorkspaceApp = ({
const isInitialPluginsRoute = route.isPlugins;
const isInitialTemplatesRoute = route.isTemplates;
const isInitialAiPromptsRoute = route.isAiPrompts;
const isInitialCompanionRoute = route.isCompanion;
const previousRouteWasCompanion = useRef(route.isCompanion);
const isInitialExecutionCenterRoute = route.isExecutionCenter;
const isInitialMobileEditorReturn = Boolean(route.mobileEditorReturnMemoId);
const isTrashRoute = route.isTrash;
const [rendererRecoveryMode, setRendererRecoveryMode] = useState(() =>
Boolean(window.edgeeverDesktop?.recoveredAfterAbnormalExit) || isRendererRecoveryRequired()
);
const [activePane, setActivePane] = useState<Pane>(() => ((isInitialSettingsRoute || isInitialPluginsRoute || isInitialTemplatesRoute || isInitialAiPromptsRoute || isInitialCompanionRoute || isInitialExecutionCenterRoute) && !isInitialMobileEditorReturn ? "editor" : "memos"));
const [activePane, setActivePane] = useState<Pane>(() => ((isInitialSettingsRoute || isInitialPluginsRoute || isInitialTemplatesRoute || isInitialAiPromptsRoute || isInitialExecutionCenterRoute) && !isInitialMobileEditorReturn ? "editor" : "memos"));
const [memoView, setMemoView] = useState<MemoView>(() => (isTrashRoute ? "trash" : "notebook"));
const {
beginMemoSelection,
@@ -488,7 +483,7 @@ export const WorkspaceApp = ({
setShortcutSettings,
shortcutSettings,
} = useWorkspacePreferences();
const [rightView, setRightView] = useState<"editor" | "settings" | "plugins" | "assets" | "tags" | "templates" | "ai-prompts" | "companion" | "execution-center" | "evernote-migration">(() =>
const [rightView, setRightView] = useState<"editor" | "settings" | "plugins" | "assets" | "tags" | "templates" | "ai-prompts" | "execution-center" | "evernote-migration">(() =>
isInitialSettingsRoute
? "settings"
: isInitialPluginsRoute
@@ -497,8 +492,6 @@ export const WorkspaceApp = ({
? "templates"
: isInitialAiPromptsRoute
? "ai-prompts"
: isInitialCompanionRoute
? "companion"
: isInitialExecutionCenterRoute
? "execution-center"
: "editor"
@@ -510,8 +503,6 @@ export const WorkspaceApp = ({
? "settings"
: isInitialTemplatesRoute || isInitialAiPromptsRoute
? "templates"
: isInitialCompanionRoute && !isInitialMobileEditorReturn
? "companion"
: "home"
);
const [mobileSearchFocusToken, setMobileSearchFocusToken] = useState(0);
@@ -684,9 +675,7 @@ export const WorkspaceApp = ({
templatesOpen ||
memoSelectionModeActive ||
// A routed workspace uses browser history, not a synthetic modal back layer.
(!route.isCompanion && rightView !== "companion" && (
rightView !== "editor" || visibleActivePane === "editor" || visibleActivePane === "notebooks"
))
(rightView !== "editor" || visibleActivePane === "editor" || visibleActivePane === "notebooks")
);
const mobilePullToRefreshActive = Boolean(
!isDesktop &&
@@ -834,8 +823,6 @@ export const WorkspaceApp = ({
}, []);
useEffect(() => {
const returningFromCompanion = previousRouteWasCompanion.current;
previousRouteWasCompanion.current = route.isCompanion;
if (route.isSettings) {
skipNextHomeRouteSyncRef.current = false;
setRightView("settings");
@@ -868,14 +855,6 @@ export const WorkspaceApp = ({
return;
}
if (route.isCompanion) {
skipNextHomeRouteSyncRef.current = false;
setRightView("companion");
setMobileBottomNavActive("companion");
setActivePane("editor");
return;
}
if (route.isExecutionCenter) {
skipNextHomeRouteSyncRef.current = false;
setRightView("execution-center");
@@ -892,8 +871,7 @@ export const WorkspaceApp = ({
setMemoView(isTrashRoute ? "trash" : "notebook");
setRightView("editor");
setMobileBottomNavActive("home");
if (returningFromCompanion) setActivePane("memos");
}, [isTrashRoute, route.isSettings, route.isPlugins, route.isTemplates, route.isAiPrompts, route.isCompanion, route.isExecutionCenter]);
}, [isTrashRoute, route.isSettings, route.isPlugins, route.isTemplates, route.isAiPrompts, route.isExecutionCenter]);
useEffect(() => {
if (window.edgeeverDesktop?.isAvailable) {
@@ -1664,13 +1642,20 @@ export const WorkspaceApp = ({
};
const handleImportScreenshot = useCallback(async (payload: { name: string; type: string; title?: string; bytes: Uint8Array }) => {
const importKey = screenshotImportDedupeKey(payload);
if (!screenshotImportGate.tryBegin(importKey)) return;
const notebookId = selectedNotebookId && notebooks.some((notebook) => notebook.id === selectedNotebookId) && memoView !== "trash"
? selectedNotebookId
: defaultMemoNotebookId;
if (!notebookId) return;
if (!notebookId) {
screenshotImportGate.fail(importKey);
return;
}
const file = screenshotFileFromImportPayload(payload);
if (file.size === 0) {
screenshotImportGate.fail(importKey);
setAppNoticeDialog({
title: t("memoList.importScreenshotFailedTitle"),
description: t("memoList.importScreenshotEmpty"),
@@ -1711,7 +1696,9 @@ export const WorkspaceApp = ({
});
await putLocalMemo(localDataScope, memo);
revealCreatedMemo(memo);
screenshotImportGate.finish(importKey);
} catch {
screenshotImportGate.fail(importKey);
creatingMemoSelectionRef.current = false;
setAppNoticeDialog({
title: t("memoList.importScreenshotFailedTitle"),
@@ -2263,14 +2250,6 @@ export const WorkspaceApp = ({
setActivePane("editor");
};
const handleOpenCompanion = () => {
clearHiddenMobileSearch();
navigateWorkspaceCompanion();
setRightView("companion");
setMobileBottomNavActive("companion");
setActivePane("editor");
};
const handleOpenSettings = () => {
clearHiddenMobileSearch();
navigateWorkspaceSettings();
@@ -2467,11 +2446,6 @@ export const WorkspaceApp = ({
return true;
}
if (rightView === "companion") {
handleSelectAllMemos();
return true;
}
if (rightView === "settings") {
handleCloseSettings();
return true;
@@ -2800,8 +2774,6 @@ export const WorkspaceApp = ({
? t("templates.title")
: rightView === "ai-prompts"
? t("aiPrompts.title")
: rightView === "companion"
? t("companion.title")
: rightView === "execution-center"
? t("executionHistory.centerTitle")
: rightView === "evernote-migration"
@@ -2820,16 +2792,6 @@ export const WorkspaceApp = ({
: isStandaloneRuntime
? t("workspace.pullToRefresh.pullNotes")
: t("workspace.pullToRefresh.pullPage");
const editorCompanionDiscoveryHub = authRequired && Boolean(user) && !demoMode ? (
<Suspense fallback={null}>
<CompanionDiscoveryHub key={localDataScope} scope={localDataScope} onOpenNote={handleOpenPluginNote} onOpenSettings={handleOpenSettings}
onNotesChanged={async () => {
const result = await refreshWorkspaceFromServer("manual");
if ("skipped" in result && result.skipped) throw new Error("Workspace refresh was skipped.");
}} />
</Suspense>
) : null;
return (
<WorkspaceMotionProvider>
<div className="edgeever-workspace-shell flex h-[100dvh] overflow-hidden text-slate-950">
@@ -2907,7 +2869,6 @@ export const WorkspaceApp = ({
onOpenAssets={handleOpenAssets}
onOpenTags={handleOpenTags}
onOpenTemplates={handleOpenTemplates}
companionActive={rightView === "companion"}
pluginHost={pluginHost}
onOpenPluginManager={handleOpenPluginManager}
onOpenSettings={handleOpenSettings}
@@ -3087,8 +3048,6 @@ export const WorkspaceApp = ({
<m.div key={rightView} className="h-full min-h-0 min-w-0" {...paneEnterMotion}>
{rightView === "settings" ? (
<SettingsPane
companionScope={localDataScope}
onOpenCompanion={handleOpenCompanion}
onOpenExecutionCenter={handleOpenExecutionCenter}
onClose={handleCloseSettings}
onOpenTemplates={handleOpenTemplates}
@@ -3133,18 +3092,6 @@ export const WorkspaceApp = ({
/>
) : rightView === "ai-prompts" ? (
<AiPromptsPane key={localDataScope} onClose={handleCloseAiPrompts} onOpenExecutionCenter={handleOpenExecutionCenter} />
) : rightView === "companion" ? (
<CompanionPane key={localDataScope} available={authRequired && Boolean(user) && !demoMode} onBack={handleSelectAllMemos} onOpenSettings={handleOpenSettings}
beforeApply={async () => {
const { assertCompanionChangesSynced } = await import("@/lib/companion-actions");
await assertCompanionChangesSynced(localDataScope);
}}
onNotesChanged={async () => {
const result = await refreshWorkspaceFromServer("manual");
if ("skipped" in result && result.skipped) throw new Error("Workspace refresh was skipped.");
}}
onOpenNote={handleOpenPluginNote}
/>
) : rightView === "execution-center" ? (
<ExecutionCenterPane currentDeviceId={scheduledTaskDeviceId} onClose={handleCloseExecutionCenter} />
) : rightView === "evernote-migration" ? (
@@ -3196,12 +3143,11 @@ export const WorkspaceApp = ({
onSaveAsTemplate={handleSaveAsTemplate}
onToggleDesktopFocusMode={toggleDesktopFocusMode}
onOpenExecutionCenter={handleOpenExecutionCenter}
companionDiscoveryHub={editorCompanionDiscoveryHub}
/>
) : (
<EditorPane
onOpenExecutionCenter={handleOpenExecutionCenter}
companionDiscoveryHub={editorCompanionDiscoveryHub}
memo={selectedMemo}
repository={repository}
pluginHost={pluginHost}
@@ -10,8 +10,6 @@ describe("AI assistant modes", () => {
expect(source).not.toContain('"organize"');
expect(source).not.toContain("inset-y-3 right-3");
expect(source).toContain("<CompanionChat");
expect(source).toContain("allowWrites");
expect(source).toContain("forceAllowNotes");
expect(editor).toContain("companionAvailable={companionAvailable}");
expect(editor).toContain("beforeCompanionApply={beforeCompanionApply}");
});
@@ -700,9 +700,6 @@ export const AiAssistantDialog = ({
{chatting ? (
<CompanionChat
available={companionAvailable}
variant="assistant"
allowWrites
forceAllowNotes
focus={{ memoId, notebookId, title, selectionMarkdown }}
placeholder={t("aiAssistant.modes.askPlaceholder")}
beforeApply={beforeCompanionApply ?? (async () => undefined)}
@@ -1,262 +0,0 @@
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 {
SETTINGS_CARD_DESCRIPTION_CLASSNAME,
SETTINGS_CARD_HEADER_CLASSNAME,
SETTINGS_CARD_ICON_CLASSNAME,
SETTINGS_CARD_TITLE_CLASSNAME,
} from "./settings-ui";
import { Switch } from "@/components/ui/switch";
import { api, ApiRequestError } from "@/lib/api";
import { discoveryFeedKey, discoverySettingsKey, useCompanionDiscoverySettings } from "@/hooks/useCompanionDiscovery";
export function CompanionDiscoverySettingsCard({ scope, onOpenCompanion, onOpenAiSettings }: {
scope: string; onOpenCompanion: () => void; onOpenAiSettings: () => void;
}) {
const { t, i18n } = useTranslation();
const query = useCompanionDiscoverySettings(scope);
const client = useQueryClient();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<"settings" | "model" | null>(null);
const settings = query.data;
const locale = i18n.resolvedLanguage ?? i18n.language;
const aiQuery = useQuery({ queryKey: ["ai-settings", locale], queryFn: () => api.getAiSettings(locale), staleTime: 60_000 });
const aiSettings = aiQuery.data;
const defaultModelReady = Boolean(aiSettings?.encryptionConfigured && aiSettings.providers.some(provider =>
provider.isEnabled && provider.models.some(model => model.id === aiSettings.defaultModelId)));
const enabled = settings?.enabled === true;
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",
}).format(new Date(value));
const save = async (enabled: boolean, preferences?: { learningEnabled?: boolean; useMemory?: boolean }) => {
if (!settings || busy) return;
setBusy(true);
setError(null);
try {
if (enabled && !settings.enabled) {
const current = aiSettings ?? (await aiQuery.refetch()).data;
const ready = Boolean(current?.encryptionConfigured && current.providers.some(provider =>
provider.isEnabled && provider.models.some(model => model.id === current.defaultModelId)));
if (!ready) { setError("model"); return; }
}
const result = await api.saveCompanionDiscoverySettings({ enabled, version: settings.version, ...preferences });
client.setQueryData(discoverySettingsKey(scope), result.settings);
client.setQueryData(discoveryFeedKey(scope), []);
} catch (cause) {
setError(cause instanceof ApiRequestError && cause.code?.startsWith("ai_") ? "model" : "settings");
await query.refetch();
} finally {
setBusy(false);
}
};
return (
<Card className="shadow-none">
<CardHeader className={SETTINGS_CARD_HEADER_CLASSNAME}>
<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">
<CardTitle className={SETTINGS_CARD_TITLE_CLASSNAME}>
<Sparkles className={SETTINGS_CARD_ICON_CLASSNAME} />
{t("companion.discovery.settingsTitle")}
</CardTitle>
<span className="inline-flex items-center rounded-full border border-emerald-200/80 bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-emerald-700 ">
Beta
</span>
</div>
<CardDescription className={SETTINGS_CARD_DESCRIPTION_CLASSNAME}>
{t("companion.discovery.description")}
</CardDescription>
</div>
<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 "
>
{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 ">
<span className="text-sm leading-none" aria-hidden="true">🐾</span>
<p className="font-medium text-emerald-700 ">{t("companion.discovery.tagline")}</p>
</div>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-5 sm:px-5">
{error === "settings" || query.isError ? (
<p role="alert" className="text-sm text-destructive">
{t("companion.discovery.settingsFailed")}
</p>
) : null}
{error === "model" || (aiSettings && !defaultModelReady) ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 ">
<p role="alert" className="text-sm text-amber-900 ">{t("companion.discovery.modelRequired")}</p>
<button type="button" onClick={onOpenAiSettings}
className="rounded-md border border-amber-300 bg-card px-3 py-1.5 text-xs font-medium text-amber-900 hover:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-600 ">
{t("companion.discovery.configureModel")}
</button>
</div>
) : null}
<div className="space-y-3 rounded-lg border p-4">
<p className="text-sm text-slate-500">{t("companion.learning.help")}</p>
{(["learningEnabled", "useMemory"] as const).map(key => <div key={key} className="flex items-center justify-between gap-3">
<label htmlFor={`paw-${key}`} className="text-sm">{t(`companion.learning.${key}`)}</label>
<Switch id={`paw-${key}`} checked={settings?.[key] ?? false} disabled={busy || !settings}
onCheckedChange={value => void save(enabled, { [key]: value })} />
</div>)}
</div>
<section
className="space-y-3 rounded-lg border border-slate-200/80 bg-card p-4 "
aria-labelledby="companion-discovery-status-title"
>
<div className="flex items-center justify-between gap-3 border-b border-slate-200/60 pb-3 ">
<h3
id="companion-discovery-status-title"
className="flex items-center gap-2 text-sm font-medium text-slate-900 "
>
<Activity className="h-4 w-4 text-slate-500 " />
{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 "
: settings?.lastStatus === "running"
? "border border-amber-200/80 bg-amber-50 text-amber-700 "
: "border border-emerald-200/80 bg-emerald-50 text-emerald-700 "
: "border border-slate-200 bg-slate-100 text-slate-600 "
}`}
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 ">
<Clock className="h-3.5 w-3.5 text-slate-400 " />
{t("companion.discovery.transparency.lastCheck")}
</dt>
<dd className="font-medium text-slate-800 ">
{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 ">
<Sparkles className="h-3.5 w-3.5 text-slate-400 " />
{t("companion.discovery.transparency.nextCheck")}
</dt>
<dd className="text-xs leading-relaxed text-slate-600 sm:max-w-md sm:text-right ">
{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 ">
<ShieldCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-600 " />
<p className="text-xs leading-relaxed text-slate-500 ">
{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 "
>
{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-card px-4 py-6 text-center ">
<div className="mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-emerald-50 text-emerald-600 ">
<Sparkles className="h-4 w-4" />
</div>
<p className="max-w-md text-xs text-slate-500 sm:text-sm ">
{t("companion.discovery.transparency.noDiscovery")}
</p>
</div>
) : null}
{latest ? (
<article className="space-y-2.5 rounded-lg border border-slate-200/80 bg-card p-4 shadow-sm transition-colors ">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 pb-2 ">
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 ">
{t(`companion.discovery.kind.${latest.kind}`)}
</span>
<time className="text-xs text-slate-400 " dateTime={latest.createdAt}>
{formatTime(latest.createdAt)}
</time>
</div>
<h4 className="break-words text-sm font-semibold text-slate-900 ">{latest.title}</h4>
<p className="whitespace-pre-wrap break-words text-xs leading-relaxed text-slate-600 sm:text-sm ">
{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-card 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 "
>
<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 ">
<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 ">
{t("companion.discovery.openCompanion")}
</div>
<div className="text-xs text-slate-500 ">
{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 " />
</button>
</div>
</CardContent>
</Card>
);
}
@@ -1,9 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
export const discoverySettingsKey = (scope: string) => ["companion-discovery-settings", scope];
export const discoveryFeedKey = (scope: string) => ["companion-discovery-feed", scope];
export function useCompanionDiscoverySettings(scope: string) {
return useQuery({ queryKey: discoverySettingsKey(scope), queryFn: async () => (await api.getCompanionDiscoverySettings()).settings,
staleTime: 60_000, retry: false });
}
@@ -9,7 +9,6 @@ describe("workspace route resolution", () => {
isPlugins: false,
isTemplates: false,
isAiPrompts: false,
isCompanion: false,
isExecutionCenter: false,
});
expect(resolveWorkspaceRoute("/", "?view=trash").isTrash).toBe(true);
@@ -18,7 +17,6 @@ describe("workspace route resolution", () => {
expect(resolveWorkspaceRoute("/plugins/org.edgeever.example", "").isPlugins).toBe(true);
expect(resolveWorkspaceRoute("/templates", "").isTemplates).toBe(true);
expect(resolveWorkspaceRoute("/ai-prompts", "").isAiPrompts).toBe(true);
expect(resolveWorkspaceRoute("/companion", "").isCompanion).toBe(true);
expect(resolveWorkspaceRoute("/execution-center", "").isExecutionCenter).toBe(true);
});
@@ -27,17 +25,6 @@ describe("workspace route resolution", () => {
expect(resolveWorkspaceRoute("/other", "?view=trash").isTrash).toBe(false);
});
test("keeps the companion a standalone destination, independent of prompts and trash", () => {
expect(resolveWorkspaceRoute("/companion", "?view=trash")).toMatchObject({
isCompanion: true,
isAiPrompts: false,
isSettings: false,
isTrash: false,
});
expect(resolveWorkspaceRoute("/ai-prompts", "").isCompanion).toBe(false);
expect(resolveWorkspaceRoute("/companion-other", "").isCompanion).toBe(false);
});
test("extracts a standalone mobile editor return id", () => {
expect(resolveWorkspaceRoute("/", "?mobileEditorReturn=memo-42").mobileEditorReturnMemoId).toBe("memo-42");
});
-8
View File
@@ -6,7 +6,6 @@ export const WORKSPACE_SETTINGS_PATH = "/settings";
export const WORKSPACE_PLUGINS_PATH = "/plugins";
export const WORKSPACE_TEMPLATES_PATH = "/templates";
export const WORKSPACE_AI_PROMPTS_PATH = "/ai-prompts";
export const WORKSPACE_COMPANION_PATH = "/companion";
export const WORKSPACE_EXECUTION_CENTER_PATH = "/execution-center";
export const WORKSPACE_TRASH_SEARCH = "?view=trash";
@@ -17,7 +16,6 @@ export type WorkspaceRouteState = {
isPlugins: boolean;
isTemplates: boolean;
isAiPrompts: boolean;
isCompanion: boolean;
isExecutionCenter: boolean;
isTrash: boolean;
mobileEditorReturnMemoId: string | null;
@@ -30,7 +28,6 @@ export const resolveWorkspaceRoute = (pathname: string, search: string): Workspa
isPlugins: pathname === WORKSPACE_PLUGINS_PATH || pathname.startsWith(`${WORKSPACE_PLUGINS_PATH}/`),
isTemplates: pathname === WORKSPACE_TEMPLATES_PATH,
isAiPrompts: pathname === WORKSPACE_AI_PROMPTS_PATH,
isCompanion: pathname === WORKSPACE_COMPANION_PATH,
isExecutionCenter: pathname === WORKSPACE_EXECUTION_CENTER_PATH,
isTrash: pathname === "/" && search === WORKSPACE_TRASH_SEARCH,
mobileEditorReturnMemoId: new URLSearchParams(search).get(MOBILE_EDITOR_RETURN_PARAM),
@@ -72,10 +69,6 @@ export const useWorkspaceRoute = () => {
if (!route.isExecutionCenter) navigate(WORKSPACE_EXECUTION_CENTER_PATH);
}, [navigate, route.isExecutionCenter]);
const navigateCompanion = useCallback(() => {
if (!route.isCompanion) navigate(WORKSPACE_COMPANION_PATH);
}, [navigate, route.isCompanion]);
return {
route,
navigateHome,
@@ -83,7 +76,6 @@ export const useWorkspaceRoute = () => {
navigatePlugins,
navigateTemplates,
navigateAiPrompts,
navigateCompanion,
navigateExecutionCenter,
navigateTrash,
};
+1 -1
View File
@@ -34,7 +34,7 @@ export type ShortcutBinding = {
alt: boolean;
};
export type ShortcutSettings = Record<ShortcutAction, ShortcutBinding>;
export type MobileBottomNavItem = "home" | "search" | "templates" | "settings" | "companion";
export type MobileBottomNavItem = "home" | "search" | "templates" | "settings";
export type MemoContextMenuState = { memo: MemoSummary; x: number; y: number };
export type MemoDocumentAction =
| "share"
-14
View File
@@ -1,14 +0,0 @@
import type { CompanionMemory } from "@edgeever/shared";
import type { TFunction } from "i18next";
export function companionMemoryText(memory: CompanionMemory, t: TFunction): string {
if (memory.kind === "inferred" && memory.ruleKey && memory.scopeNotebookName) {
try {
const [kind, , tag] = JSON.parse(memory.ruleKey);
if ((kind === "move" || kind === "tag") && typeof tag === "string") {
return t(`companion.learning.rule.${kind}`, { tag, notebook: memory.scopeNotebookName });
}
} catch { /* Imported or older records retain their original text. */ }
}
return memory.content;
}
-1
View File
@@ -704,7 +704,6 @@ export const enUS = {
general: "General",
ai: "AI Integrations",
data: "Import & Export",
paw: "Paw mode",
advanced: "Advanced",
account: "Login Settings",
},
-1
View File
@@ -704,7 +704,6 @@ export const ja = {
general: "一般",
ai: "AI 連携",
data: "インポートとエクスポート",
paw: "肉球モード",
advanced: "詳細",
account: "ログイン設定",
},
-1
View File
@@ -704,7 +704,6 @@ export const zhCN = {
general: "常规设置",
ai: "AI集成",
data: "导入导出",
paw: "猫爪模式",
advanced: "高级设置",
account: "登录设置",
},