feat(desktop,server,web): app icons, completion notifications, single-user mode, bundled penguin CLI (#226)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-06 20:36:50 +08:00
committed by GitHub
parent 91f3292ab5
commit 082ab08e0b
34 changed files with 1404 additions and 100 deletions
@@ -12,6 +12,7 @@ import { useVersionInfo } from "../../lib/use-version-info";
import { useAuth } from "../../state/auth";
import { useProject } from "../../state/project";
import { useSessions } from "../../state/sessions";
import { useCompletionNotifications } from "../../state/use-completion-notifications";
import { Drawer } from "../ui/drawer";
import { GlyphIcon } from "../ui/glyph-icon";
import { NAV_ICONS } from "../ui/icons";
@@ -167,6 +168,9 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) {
export function AppLayout() {
const { user, desktopMode } = useAuth();
// Desktop shell only (gated inside): system notification when a task finishes while
// the window is unfocused.
useCompletionNotifications();
const [drawerOpen, setDrawerOpen] = useState(false);
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
// Desktop sidebar collapse (persisted): collapsed state leaves a narrow rail to expand from.
@@ -172,7 +172,7 @@ export function CreateProjectDialog({
* deletion (owner); members see the name and member list read-only.
*/
export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { user } = useAuth();
const { user, desktopMode } = useAuth();
const { currentProject, setCurrentProjectId, projects, reloadProjects } = useProject();
const [members, setMembers] = useState<MemberInfo[] | null>(null);
const [newMemberId, setNewMemberId] = useState("");
@@ -196,14 +196,18 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
setConfirmDelete(false);
setName(savedName);
setNameError(undefined);
api
.listMembers(projectId)
.then((res) => setMembers(res.members))
.catch((e: unknown) => setLoadError(apiErrorText(e)));
// Desktop mode is single-user: the member section is hidden below and the server
// rejects the member routes (desktop_single_user), so nothing is fetched.
if (!desktopMode) {
api
.listMembers(projectId)
.then((res) => setMembers(res.members))
.catch((e: unknown) => setLoadError(apiErrorText(e)));
}
// savedName is read at open time only: retyping in the field must not be clobbered by a
// list refresh, and reopening the dialog re-seeds it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, projectId]);
}, [open, projectId, desktopMode]);
if (!currentProject || !projectId) return null;
@@ -306,76 +310,85 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
<p className="mt-1 font-mono text-xs text-gray-400">{projectId}</p>
</div>
<div>
<p className="mb-2 text-xs font-medium text-gray-500">{S.project.members}</p>
{loadError ? (
<p className="text-xs text-red-600 dark:text-red-400">{loadError}</p>
) : members === null ? (
<p className="text-xs text-gray-400">{S.common.loading}</p>
) : (
// Member permission table: username / role / actions; cells never wrap.
// Last row (owner only) = add member: small username input + add button (new members are always the member role).
<div className="overflow-x-auto rounded-md border border-gray-200 dark:border-gray-800">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500 dark:border-gray-800 dark:bg-gray-900/60 dark:text-gray-400">
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium">
{S.common.username}
</th>
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium">{S.common.role}</th>
<th className="w-20 whitespace-nowrap px-2.5 py-1.5 text-right font-medium">
{S.common.actions}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800/60">
{members.map((m) => (
<tr key={m.userId}>
<td className="whitespace-nowrap px-2.5 py-1.5">{m.userId}</td>
<td className="whitespace-nowrap px-2.5 py-1.5">
<Badge tone="gray">{m.role}</Badge>
</td>
<td className="whitespace-nowrap px-2.5 py-1 text-right">
{isOwner && m.role !== "owner" && m.userId !== user?.userId && (
<Button size="sm" variant="ghost" onClick={() => void doRemove(m.userId)}>
{S.project.removeMember}
{/* Member management does not exist in the single-user desktop app. */}
{!desktopMode && (
<div>
<p className="mb-2 text-xs font-medium text-gray-500">{S.project.members}</p>
{loadError ? (
<p className="text-xs text-red-600 dark:text-red-400">{loadError}</p>
) : members === null ? (
<p className="text-xs text-gray-400">{S.common.loading}</p>
) : (
// Member permission table: username / role / actions; cells never wrap.
// Last row (owner only) = add member: small username input + add button (new members are always the member role).
<div className="overflow-x-auto rounded-md border border-gray-200 dark:border-gray-800">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500 dark:border-gray-800 dark:bg-gray-900/60 dark:text-gray-400">
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium">
{S.common.username}
</th>
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium">
{S.common.role}
</th>
<th className="w-20 whitespace-nowrap px-2.5 py-1.5 text-right font-medium">
{S.common.actions}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800/60">
{members.map((m) => (
<tr key={m.userId}>
<td className="whitespace-nowrap px-2.5 py-1.5">{m.userId}</td>
<td className="whitespace-nowrap px-2.5 py-1.5">
<Badge tone="gray">{m.role}</Badge>
</td>
<td className="whitespace-nowrap px-2.5 py-1 text-right">
{isOwner && m.role !== "owner" && m.userId !== user?.userId && (
<Button
size="sm"
variant="ghost"
onClick={() => void doRemove(m.userId)}
>
{S.project.removeMember}
</Button>
)}
</td>
</tr>
))}
{isOwner && (
<tr>
<td className="px-2.5 py-1.5">
<Input
placeholder={S.common.username}
size="sm"
value={newMemberId}
onChange={(e) => setNewMemberId(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addMember();
}}
/>
</td>
<td className="whitespace-nowrap px-2.5 py-1.5">
<Badge tone="gray">member</Badge>
</td>
<td className="whitespace-nowrap px-2.5 py-1 text-right">
<Button
size="sm"
disabled={!newMemberId.trim()}
onClick={() => void addMember()}
>
{S.project.addMember}
</Button>
)}
</td>
</tr>
))}
{isOwner && (
<tr>
<td className="px-2.5 py-1.5">
<Input
placeholder={S.common.username}
size="sm"
value={newMemberId}
onChange={(e) => setNewMemberId(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addMember();
}}
/>
</td>
<td className="whitespace-nowrap px-2.5 py-1.5">
<Badge tone="gray">member</Badge>
</td>
<td className="whitespace-nowrap px-2.5 py-1 text-right">
<Button
size="sm"
disabled={!newMemberId.trim()}
onClick={() => void addMember()}
>
{S.project.addMember}
</Button>
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
)}
<ChatDefaultsSection projectId={projectId} isOwner={isOwner} />
@@ -1119,8 +1119,10 @@ export function Sidebar({
)}
</button>
)}
{/* User management is visible only to admins (the page route also has its own guard as a fallback). */}
{user?.isAdmin && (
{/* User management is visible only to admins (the page route also has its own
guard as a fallback), and never in desktop mode: the desktop app is
single-user and the server rejects the routes (desktop_single_user). */}
{user?.isAdmin && !desktopMode && (
<button
type="button"
className={menuItemClass}
@@ -23,7 +23,7 @@ import { Modal } from "../../components/ui/modal";
export function AdminUsersPage() {
useDocumentTitle(S.admin.users);
const { user } = useAuth();
const { user, desktopMode } = useAuth();
const [users, setUsers] = useState<UserInfo[] | null>(null);
const [listError, setListError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
@@ -40,11 +40,13 @@ export function AdminUsersPage() {
}, []);
useEffect(() => {
if (user?.isAdmin) void reload();
}, [user?.isAdmin, reload]);
if (user?.isAdmin && !desktopMode) void reload();
}, [user?.isAdmin, desktopMode, reload]);
// Route guard fallback: non-admins are redirected back to the chat page (the sidebar has no entry point anyway).
if (user && !user.isAdmin) return <Navigate to="/chat" replace />;
// Route guard fallback: non-admins are redirected back to the chat page (the sidebar has
// no entry point anyway); same for desktop mode, where the app is single-user and the
// server rejects the admin-user routes (desktop_single_user).
if (user && (!user.isAdmin || desktopMode)) return <Navigate to="/chat" replace />;
return (
<div className="h-full overflow-y-auto p-4 md:p-6">
+75
View File
@@ -0,0 +1,75 @@
/**
* Task-completion detection over the tracked Session list (pure logic, node-testable —
* the desktop notification hook in state/use-completion-notifications.ts drives it).
*
* A Session is "active" while running or compacting; a completion is the transition of
* a KNOWN active Session to idle. First observations never fire — a freshly loaded list
* only states where sessions already are, it doesn't witness a finish. Because the
* transition flips the stored state, each run fires at most once (the per-session+run
* dedupe); on top of that, a short per-session cooldown swallows the flap where a stale
* list snapshot racing the live SSE flip briefly resurrects "running" and would
* otherwise double-notify the same finish.
*/
import type { SessionStatus } from "@prismshadow/penguin-server/api";
export interface ObservedSession {
sessionId: string;
status: SessionStatus;
}
/** Whether a status counts as an in-flight run. */
function isActive(status: SessionStatus): boolean {
return status === "running" || status === "compacting";
}
/** Minimum spacing between two completion firings of the same Session (see header). */
export const COMPLETION_COOLDOWN_MS = 10_000;
export interface CompletionTracker {
/**
* Feed the current Session list; returns the sessionIds whose run just completed.
* `now` is injectable for tests (defaults to Date.now()).
*/
observe(list: readonly ObservedSession[], now?: number): string[];
}
export function createCompletionTracker(
cooldownMs: number = COMPLETION_COOLDOWN_MS,
): CompletionTracker {
interface Entry {
active: boolean;
lastFiredAt: number | null;
}
const entries = new Map<string, Entry>();
return {
observe(list, now = Date.now()) {
const completed: string[] = [];
const seen = new Set<string>();
for (const s of list) {
// Defensive: the list is deduplicated upstream, but only the first occurrence
// of an id may drive the state machine.
if (seen.has(s.sessionId)) continue;
seen.add(s.sessionId);
const active = isActive(s.status);
const prev = entries.get(s.sessionId);
if (prev === undefined) {
entries.set(s.sessionId, { active, lastFiredAt: null });
continue;
}
if (prev.active && !active) {
if (prev.lastFiredAt === null || now - prev.lastFiredAt >= cooldownMs) {
completed.push(s.sessionId);
prev.lastFiredAt = now;
}
}
prev.active = active;
}
// Prune vanished sessions (deleted / Project switch): reappearing later counts as
// a fresh first observation, which never fires.
for (const id of [...entries.keys()]) {
if (!seen.has(id)) entries.delete(id);
}
return completed;
},
};
}
+8
View File
@@ -92,6 +92,13 @@ export const en: Strings = {
adminOnly: "Only an administrator can run the update from here.",
},
/** Desktop task-completion notifications (window unfocused; desktop-shell sessions only). */
notify: {
taskCompleteTitle: "Task completed",
/** `session` is the Session title (defaultSessionTitle when unnamed). */
taskCompleteBody: (session: string): string => `"${session}" has finished — click to view`,
},
common: {
save: "Save",
cancel: "Cancel",
@@ -1143,6 +1150,7 @@ Scenarios:
password_mismatch: "The current password is incorrect.",
invalid_password: "Password must be at least 8 characters.",
admin_required: "Only an admin can perform this operation.",
desktop_single_user: "The desktop app is single-user; user management is unavailable.",
not_found: "This resource does not exist, or you do not have access.",
agent_not_found: "This agent no longer exists.",
unknown_agent: "That agent does not exist in this Project.",
+8
View File
@@ -92,6 +92,13 @@ export const zh = {
adminOnly: "只有管理员可以在这里执行更新。",
},
/** Desktop task-completion notifications (window unfocused; desktop-shell sessions only). */
notify: {
taskCompleteTitle: "任务完成",
/** `session` is the Session title (defaultSessionTitle when unnamed). */
taskCompleteBody: (session: string): string => `「${session}」已完成,点击查看`,
},
common: {
save: "保存",
cancel: "取消",
@@ -1117,6 +1124,7 @@ Benchmark:
password_mismatch: "当前密码不正确。",
invalid_password: "密码至少 8 位。",
admin_required: "仅管理员可执行此操作。",
desktop_single_user: "桌面应用为单用户模式,用户管理不可用。",
not_found: "资源不存在,或你没有访问权限。",
agent_not_found: "该 Agent 已不存在。",
unknown_agent: "该 Agent 不存在于本 Project。",
@@ -0,0 +1,68 @@
/**
* Desktop task-completion notifications, renderer-side only (standard Web Notification
* API — no preload, no private IPC, per design § "桌面端原型 · 进程模型").
*
* Watches the tracked Session list for active→idle transitions (lib/completion-notify)
* and, when the window is hidden or unfocused, shows a system notification with the
* Session's title; clicking focuses the window and opens that Session. Gated to
* sessionVia === "desktop": only the desktop shell's own window qualifies — there the
* Notification permission is granted by default, so plain browsers (which would face a
* permission prompt) are never touched.
*/
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router";
import { S } from "../lib/strings";
import { createCompletionTracker } from "../lib/completion-notify";
import { useAuth } from "./auth";
import { useProject } from "./project";
import { useSessions } from "./sessions";
export function useCompletionNotifications(): void {
const { sessionVia } = useAuth();
const { sessions } = useSessions();
const { setCurrentAgentId } = useProject();
const navigate = useNavigate();
const trackerRef = useRef(createCompletionTracker());
// Click handlers fire long after the effect ran: read the latest helpers via refs.
const navigateRef = useRef(navigate);
navigateRef.current = navigate;
const setCurrentAgentIdRef = useRef(setCurrentAgentId);
setCurrentAgentIdRef.current = setCurrentAgentId;
const enabled = sessionVia === "desktop";
useEffect(() => {
if (!enabled) return;
// The tracker must see every snapshot (also while focused): completions the user
// watched happen are consumed silently instead of surfacing on a later blur.
const completed = trackerRef.current.observe(
sessions.map((s) => ({ sessionId: s.sessionId, status: s.status })),
);
if (completed.length === 0) return;
if (!document.hidden && document.hasFocus()) return;
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
for (const sessionId of completed) {
const session = sessions.find((s) => s.sessionId === sessionId);
const title = session?.title ?? S.chat.defaultSessionTitle;
const agentId = session?.agentId ?? null;
try {
const notification = new Notification(S.notify.taskCompleteTitle, {
body: S.notify.taskCompleteBody(title),
// One notification per Session: a newer completion replaces the stale one.
tag: `penguin-task-${sessionId}`,
icon: "/penguin-logo.svg",
});
notification.onclick = () => {
notification.close();
window.focus();
// Mirrors the sidebar's openSession: the current agent follows the Session.
if (agentId !== null) setCurrentAgentIdRef.current(agentId);
navigateRef.current(`/chat/${sessionId}`);
};
} catch {
// Notification construction is best-effort; a platform refusing it must not
// break the app.
}
}
}, [enabled, sessions]);
}