From ffb008898882f54f57531399e7cb4f1ff83b798d Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:31:57 -0300 Subject: [PATCH] feat(app): mark a bot unread, count unread bots per crew, mark the icon Follow-ups to the unread replies in the sidebar: - "Mark as unread" in a bot's menu (sidebar right-click and header), "Mark as read" on an unread one. The mark stays (`botloft.unread`) until the chat is opened; marking the open chat puts it away, back on the crew's page. - Beside each crew's name, how many of its bots are unread. - Settings > Notifications, on Windows: "Mark the icon when a bot replies" (on by default) shows the taskbar mark while a bot is unread, as it does when something waits for the owner. The seen marks and their actions move to store/seen.ts, which keeps store/app.ts shorter. --- app/src/features/account/AlertsSettings.tsx | 10 +++ app/src/features/bots/botActions.tsx | 18 ++++- app/src/features/crews/Sidebar.tsx | 11 ++- app/src/features/crews/botMenu.test.tsx | 1 + app/src/features/crews/unread.test.tsx | 66 ++++++++++++++- app/src/i18n/en/account.ts | 2 + app/src/i18n/en/bots.ts | 2 + app/src/i18n/en/crews.ts | 4 +- app/src/i18n/es/account.ts | 3 + app/src/i18n/es/bots.ts | 2 + app/src/i18n/es/crews.ts | 3 +- app/src/i18n/pt-BR/account.ts | 3 + app/src/i18n/pt-BR/bots.ts | 2 + app/src/i18n/pt-BR/crews.ts | 3 +- app/src/shell/attention.ts | 6 +- app/src/shell/prefs.ts | 2 + app/src/shell/sidebarToggle.tsx | 4 +- app/src/store/app.ts | 23 ++---- app/src/store/seen.ts | 90 +++++++++++++++++++-- app/src/store/sync.ts | 3 +- docs/spec.md | 6 +- 21 files changed, 223 insertions(+), 41 deletions(-) diff --git a/app/src/features/account/AlertsSettings.tsx b/app/src/features/account/AlertsSettings.tsx index 6f734e1a..a95ddfd4 100644 --- a/app/src/features/account/AlertsSettings.tsx +++ b/app/src/features/account/AlertsSettings.tsx @@ -12,6 +12,7 @@ export function AlertsSettings() { const needs = usePref(prefs.notifyNeeds); const done = usePref(prefs.notifyDone); const sound = usePref(prefs.sound); + const replies = usePref(prefs.markReplies); const tray = usePref(prefs.tray); const keep = usePref(prefs.whenClosed) === "keep"; const nearClock = tray && keep; @@ -29,6 +30,15 @@ export function AlertsSettings() { checked={done} onChange={prefs.notifyDone.set} /> + {/* The mark on the icon is a taskbar overlay, which only Windows has. */} + {SYSTEM === "Windows" && ( + + )} {!nearClock &&

{s.alertsNeedTray}

} diff --git a/app/src/features/bots/botActions.tsx b/app/src/features/bots/botActions.tsx index 58524ce5..f2a879cc 100644 --- a/app/src/features/bots/botActions.tsx +++ b/app/src/features/bots/botActions.tsx @@ -1,8 +1,18 @@ -import { Archive, Crown, FolderOpen, Pencil, RefreshCcw, Trash2 } from "lucide-react"; +import { + Archive, + Crown, + FolderOpen, + Mail, + MailOpen, + Pencil, + RefreshCcw, + Trash2, +} from "lucide-react"; import { type ReactNode, useState } from "react"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; import { useApi, useApp, useHost } from "../../store/context"; +import { isUnread } from "../../store/seen"; import { Confirm } from "../../ui/Confirm"; import type { MenuItem } from "../../ui/Menu"; import { attempt } from "../../ui/toast"; @@ -27,6 +37,9 @@ export function useBotActions( const host = useHost(); const putBot = useApp((state) => state.putBot); const putCrew = useApp((state) => state.putCrew); + const unread = useApp((state) => isUnread(state, bot.id)); + const markSeen = useApp((state) => state.markSeen); + const markUnread = useApp((state) => state.markUnread); const chief = isChief(bot, crew); const [open, setOpen] = useState(null); const close = () => setOpen(null); @@ -54,6 +67,9 @@ export function useBotActions( icon: FolderOpen, onSelect: () => attempt(words.failed.openFolder, () => host.openPath(bot.workspace)), }, + unread + ? { label: words.markRead, icon: MailOpen, onSelect: () => markSeen(bot.id) } + : { label: words.markUnread, icon: Mail, onSelect: () => markUnread(bot.id) }, { label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") }, { label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") }, ]; diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index 7c2c7a8b..58a066cf 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -7,7 +7,7 @@ import { when } from "../../lib/format"; import type { Activity, Bot, Crew } from "../../lib/protocol.gen"; import { activityOf, botsOf, crewList } from "../../store/app"; import { useApp } from "../../store/context"; -import { hasUnreadReply } from "../../store/seen"; +import { isUnread, unreadIn } from "../../store/seen"; import { Button } from "../../ui/Button"; import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu"; import { APP_OPENED } from "../../ui/motion"; @@ -58,6 +58,7 @@ function CrewEntry({ crew }: { crew: Crew }) { const t = useT(); const bots = useApp(useShallow((state) => botsOf(state, crew.id))); const selected = useApp((state) => state.selectedCrewId === crew.id && !state.selectedBotId); + const unread = useApp((state) => unreadIn(state, crew.id)); const selectCrew = useApp((state) => state.selectCrew); return ( @@ -69,6 +70,12 @@ function CrewEntry({ crew }: { crew: Crew }) { className={`${row} h-8 gap-2 px-2.5 font-semibold text-sm ${selected ? "bg-sunken" : ""}`} > {crew.name} + {unread > 0 && ( + + {unread} + {t.crews.sidebar.unreadCount(unread)} + + )} {crew.paused && ( @@ -95,7 +102,7 @@ const Conversation = memo(function Conversation({ bot, crew }: { bot: Bot; crew: const [menuAt, setMenuAt] = useState(null); const closeMenu = useCallback(() => setMenuAt(null), []); const activity = useApp((state) => activityOf(state, bot.id)); - const unread = useApp((state) => hasUnreadReply(state, bot.id)); + const unread = useApp((state) => isUnread(state, bot.id)); return (
  • APP_OPENED ? "animate-rise" : undefined}>