Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function browseInputEndPaddingClass(input: {
export type SearchOverlayMode = "command" | "files" | "content";

export type CommandPaletteOpenIntent =
| { readonly kind: "add-project" | "new-thread-in" | "change-theme" }
| { readonly kind: "add-project" | "new-thread-in" | "change-theme" | "attention-inbox" }
| {
readonly kind: "search";
readonly query: string;
Expand All @@ -85,6 +85,7 @@ export type CommandPaletteUiAction =
| { readonly _tag: "OpenAddProject" }
| { readonly _tag: "OpenNewThreadIn" }
| { readonly _tag: "OpenChangeTheme" }
| { readonly _tag: "OpenAttentionInbox" }
| { readonly _tag: "ClearOpenIntent" };

export function reduceCommandPaletteUiState(
Expand Down Expand Up @@ -116,6 +117,8 @@ export function reduceCommandPaletteUiState(
return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } };
case "OpenChangeTheme":
return { open: true, mode: "command", openIntent: { kind: "change-theme" } };
case "OpenAttentionInbox":
return { open: true, mode: "command", openIntent: { kind: "attention-inbox" } };
case "ClearOpenIntent":
return state.openIntent ? { ...state, openIntent: null } : state;
}
Expand Down
99 changes: 96 additions & 3 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
FolderIcon,
FolderPlusIcon,
GitBranchIcon,
InboxIcon,
LayersIcon,
LinkIcon,
MessageSquareIcon,
Expand Down Expand Up @@ -83,6 +84,7 @@ import { useAtomValue } from "@effect/atom-react";

import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
import { useAttentionInbox } from "../hooks/useAttentionInbox";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
Expand Down Expand Up @@ -138,6 +140,7 @@ import {
} from "../lib/utils";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
import { formatRelativeTimeLabel } from "../timestampFormat";
import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems";
import {
applyWslEnvironmentConfiguration,
Expand Down Expand Up @@ -175,6 +178,10 @@ import { CommandPaletteResults } from "./CommandPaletteResults";
import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { ProjectFavicon } from "./ProjectFavicon";
import {
ATTENTION_REASON_PRESENTATION,
useProjectTitleByKey,
} from "./sidebar/SidebarAttentionInbox";
import { ProjectFilePicker } from "./files/ProjectFilePicker";
import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog";
import { openTranscriptExportDialog } from "./TranscriptExportDialog";
Expand Down Expand Up @@ -573,6 +580,13 @@ export function CommandPalette({ children }: { children: ReactNode }) {
});
return;
}
if (command === "attentionInbox.open") {
event.preventDefault();
event.stopPropagation();
if (event.repeat) return;
dispatch({ _tag: "OpenAttentionInbox" });
return;
}
if (command === "usage.open") {
event.preventDefault();
event.stopPropagation();
Expand Down Expand Up @@ -708,6 +722,8 @@ function OpenCommandPaletteDialog(props: {
const navigate = useNavigate();
const pathname = useLocation({ select: (location) => location.pathname });
const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props;
const attentionEntries = useAttentionInbox();
const attentionProjectTitleByKey = useProjectTitleByKey();
const [query, setQuery] = useState(openIntent?.kind === "search" ? openIntent.query : "");
const [linkedThreadSearch, setLinkedThreadSearch] = useState(
openIntent?.kind === "search" ? openIntent : null,
Expand Down Expand Up @@ -2164,6 +2180,81 @@ function OpenCommandPaletteDialog(props: {
});
}

const attentionInboxItem: CommandPaletteSubmenuItem = {
kind: "submenu",
value: "action:attention-inbox",
searchTerms: [
"needs attention",
"inbox",
"attention",
"approval",
"input",
"question",
"failed",
"completed",
"unread",
"review",
],
title: "Needs attention",
...(attentionEntries.length > 0
? {
titleTrailingContent: (
<span className="text-xs text-muted-foreground/70 tabular-nums">
{attentionEntries.length}
</span>
),
}
: {}),
icon: <InboxIcon className={ITEM_ICON_CLASS} />,
addonIcon: <InboxIcon className={ADDON_ICON_CLASS} />,
shortcutCommand: "attentionInbox.open",
groups: [
{
value: "attention-inbox",
label: "Needs attention",
items: attentionEntries.map((entry) => {
const { label, Icon, iconClassName } = ATTENTION_REASON_PRESENTATION[entry.reason];
const projectTitle = attentionProjectTitleByKey.get(
`${entry.thread.environmentId}:${entry.thread.projectId}`,
);
return {
kind: "action" as const,
value: `attention:${entry.key}`,
searchTerms: [entry.thread.title, label, projectTitle ?? ""],
title: entry.thread.title,
description: projectTitle ? `${label} · ${projectTitle}` : label,
timestamp: formatRelativeTimeLabel(entry.at),
icon: <Icon className={cn("size-4", iconClassName)} />,
run: async () => {
// The entry's own route, not its tab group's last-opened tab.
await navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(
scopeThreadRef(entry.thread.environmentId, entry.thread.id),
),
});
},
};
}),
},
],
};
actionItems.push(attentionInboxItem);

useLayoutEffect(() => {
if (openIntent?.kind !== "attention-inbox") return;
clearOpenIntent();
browseNavigation.invalidate();
cloneLookupGeneration.current += 1;
setIsRemoteProjectLookingUp(false);
setAddProjectCloneFlow(null);
setViewStack([]);
pushPaletteView({
addonIcon: <InboxIcon className={ADDON_ICON_CLASS} />,
groups: [{ value: "attention-inbox", label: "Needs attention", items: [] }],
});
}, [browseNavigation, clearOpenIntent, openIntent, pushPaletteView]);

actionItems.push({
kind: "action",
value: "action:usage",
Expand Down Expand Up @@ -2258,9 +2349,11 @@ function OpenCommandPaletteDialog(props: {
)
: currentView?.groups[0]?.value === "themes"
? changeThemeItem.groups
: currentView?.groups[0]?.value === "appearance"
? changeAppearanceItem.groups
: (currentView?.groups ?? rootGroups);
: currentView?.groups[0]?.value === "attention-inbox"
? attentionInboxItem.groups
: currentView?.groups[0]?.value === "appearance"
? changeAppearanceItem.groups
: (currentView?.groups ?? rootGroups);

const filteredGroups = filterCommandPaletteGroups({
activeGroups,
Expand Down
10 changes: 2 additions & 8 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import { hasUnseenCompletion as hasUnseenTurnCompletion } from "@t3tools/client-runtime/state/attention-inbox";
import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts";
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import type { AsyncResult } from "effect/unstable/reactivity";
Expand Down Expand Up @@ -646,14 +647,7 @@ export function useThreadJumpHintVisibility(): {
}

export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
if (!thread.latestTurn?.completedAt) return false;
const completedAt = Date.parse(thread.latestTurn.completedAt);
if (Number.isNaN(completedAt)) return false;
if (!thread.lastVisitedAt) return false;

const lastVisitedAt = Date.parse(thread.lastVisitedAt);
if (Number.isNaN(lastVisitedAt)) return true;
return completedAt > lastVisitedAt;
return hasUnseenTurnCompletion(thread.latestTurn, thread.lastVisitedAt);
}

export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ import {
} from "./ui/combobox";
import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar";
import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
import { SidebarAttentionInbox } from "./sidebar/SidebarAttentionInbox";
import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader";
import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "./ui/menu";
import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
Expand Down Expand Up @@ -5070,6 +5071,7 @@ export default function Sidebar() {
</ComboboxPopup>
</Combobox>
}
attentionInbox={<SidebarAttentionInbox />}
onNewProject={openAddProjectCommandPalette}
onNewThread={handleNewThreadClick}
newThreadDisabled={projects.length === 0}
Expand Down
164 changes: 164 additions & 0 deletions apps/web/src/components/sidebar/SidebarAttentionInbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* The sidebar's attention inbox: a header button that appears while any
* thread needs the user, and a popover listing those threads across every
* project, environment and tab group. Opening an entry lands on that exact
* chat tab. Failures and completions can be marked read here; the thread
* menu's Mark unread brings a completion back.
*/
import type { ThreadAttentionReason } from "@t3tools/client-runtime/state/attention-inbox";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { useRouter } from "@tanstack/react-router";
import {
CheckIcon,
CircleAlertIcon,
CircleCheckIcon,
InboxIcon,
MessageCircleQuestionIcon,
ShieldQuestionIcon,
type LucideIcon,
} from "lucide-react";
import { useMemo, useState } from "react";

import { useAttentionInbox, type WebAttentionInboxEntry } from "../../hooks/useAttentionInbox";
import { useProjects } from "../../state/entities";
import { buildThreadRouteParams } from "../../threadRoutes";
import { formatRelativeTimeLabel } from "../../timestampFormat";
import { cn } from "../../lib/utils";
import { useUiStateStore } from "../../uiStateStore";
import { Button } from "../ui/button";
import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
import { useSidebar } from "../ui/sidebar";
import { SidebarHeaderIconButton } from "./SidebarThreadHeader";

export const ATTENTION_REASON_PRESENTATION: Record<
ThreadAttentionReason,
{ readonly label: string; readonly Icon: LucideIcon; readonly iconClassName: string }
> = {
approval: {
label: "Approval needed",
Icon: ShieldQuestionIcon,
iconClassName: "text-warning-foreground",
},
input: {
label: "Input needed",
Icon: MessageCircleQuestionIcon,
iconClassName: "text-info-foreground",
},
failed: { label: "Failed", Icon: CircleAlertIcon, iconClassName: "text-destructive-foreground" },
completed: {
label: "Completed",
Icon: CircleCheckIcon,
iconClassName: "text-success-foreground",
},
};

/** Project titles keyed like thread shells, for the entries' second line. */
export function useProjectTitleByKey(): ReadonlyMap<string, string> {
const projects = useProjects();
return useMemo(
() =>
new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])),
[projects],
);
}

export function SidebarAttentionInbox() {
const entries = useAttentionInbox();
const [open, setOpen] = useState(false);
if (entries.length === 0 && !open) return null;
const label = `Needs attention (${entries.length})`;

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger render={<SidebarHeaderIconButton label={label} />}>
<InboxIcon />
{entries.length > 0 ? (
<span
aria-hidden
className="pointer-events-none absolute -right-0.5 -top-0.5 min-w-3.5 rounded-full bg-primary px-1 text-center text-3xs font-medium leading-3.5 text-primary-foreground tabular-nums"
>
{entries.length > 99 ? "99+" : entries.length}
</span>
) : null}
</PopoverTrigger>
<PopoverPopup align="start" width="md" padding="compact" aria-label="Needs attention">
<AttentionInboxList entries={entries} onOpenEntry={() => setOpen(false)} />
</PopoverPopup>
</Popover>
);
}

function AttentionInboxList(props: {
entries: ReadonlyArray<WebAttentionInboxEntry>;
onOpenEntry: () => void;
}) {
const router = useRouter();
const { isMobile, setOpenMobile } = useSidebar();
const markThreadVisited = useUiStateStore((state) => state.markThreadVisited);
const projectTitleByKey = useProjectTitleByKey();

return (
<div className="flex flex-col gap-1">
<PopoverTitle className="m-1">Needs attention</PopoverTitle>
{props.entries.length === 0 ? (
<p className="px-1 py-2 text-sm text-muted-foreground">Nothing needs you right now.</p>
) : (
<ul className="-mx-1 flex max-h-96 flex-col overflow-y-auto">
{props.entries.map((entry) => {
const { label, Icon, iconClassName } = ATTENTION_REASON_PRESENTATION[entry.reason];
const projectTitle = projectTitleByKey.get(
`${entry.thread.environmentId}:${entry.thread.projectId}`,
);
const canMarkRead = entry.reason === "failed" || entry.reason === "completed";
return (
<li
key={entry.key}
className="group flex items-center gap-1 rounded-md hover:bg-accent"
>
<button
type="button"
className="flex min-w-0 flex-1 cursor-pointer items-start gap-2 rounded-md px-2 py-1.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
props.onOpenEntry();
if (isMobile) setOpenMobile(false);
// The entry's own route, not its tab group's last-opened tab.
void router.navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(
scopeThreadRef(entry.thread.environmentId, entry.thread.id),
),
});
}}
>
<Icon aria-hidden className={cn("mt-0.5 size-4 shrink-0", iconClassName)} />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm text-foreground">{entry.thread.title}</span>
<span className="truncate text-xs text-muted-foreground">
{projectTitle ? `${label} · ${projectTitle}` : label}
</span>
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{formatRelativeTimeLabel(entry.at)}
</span>
</button>
{canMarkRead ? (
<Button
type="button"
size="icon-xs"
variant="ghost-muted"
className="mr-1 shrink-0"
aria-label={`Mark ${entry.thread.title} as read`}
title="Mark as read"
onClick={() => markThreadVisited(entry.key, new Date().toISOString())}
>
<CheckIcon />
</Button>
) : null}
</li>
);
})}
</ul>
)}
</div>
);
}
Loading