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:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user