mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
Merge pull request #3356 from cryppadotta/pap-1331-inbox-ux
feat: polish inbox and issue list workflows
This commit is contained in:
commit
45ebecab5a
36 changed files with 1695 additions and 390 deletions
|
|
@ -21,7 +21,6 @@ import { queryKeys } from "../lib/queryKeys";
|
|||
import {
|
||||
applyIssueFilters,
|
||||
countActiveIssueFilters,
|
||||
defaultIssueFilterState,
|
||||
type IssueFilterState,
|
||||
} from "../lib/issue-filters";
|
||||
import {
|
||||
|
|
@ -31,7 +30,12 @@ import {
|
|||
rememberIssueDetailLocationState,
|
||||
withIssueDetailHeaderSeed,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||
import {
|
||||
hasBlockingShortcutDialog,
|
||||
isKeyboardShortcutTextInputTarget,
|
||||
shouldBlurPageSearchOnEnter,
|
||||
shouldBlurPageSearchOnEscape,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import {
|
||||
|
|
@ -91,13 +95,16 @@ import {
|
|||
buildInboxNesting,
|
||||
getAvailableInboxIssueColumns,
|
||||
getApprovalsForTab,
|
||||
getArchivedInboxSearchIssues,
|
||||
getInboxWorkItems,
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getLatestFailedRunsByAgent,
|
||||
matchesInboxIssueSearch,
|
||||
getRecentTouchedIssues,
|
||||
groupInboxWorkItems,
|
||||
isInboxEntityDismissed,
|
||||
isMineInboxTab,
|
||||
loadInboxFilterPreferences,
|
||||
loadInboxIssueColumns,
|
||||
loadInboxNesting,
|
||||
loadInboxWorkItemGroupBy,
|
||||
|
|
@ -105,10 +112,13 @@ import {
|
|||
resolveInboxNestingEnabled,
|
||||
resolveIssueWorkspaceName,
|
||||
resolveInboxSelectionIndex,
|
||||
saveInboxFilterPreferences,
|
||||
saveInboxIssueColumns,
|
||||
saveInboxNesting,
|
||||
saveInboxWorkItemGroupBy,
|
||||
type InboxApprovalFilter,
|
||||
type InboxCategoryFilter,
|
||||
type InboxFilterPreferences,
|
||||
type InboxIssueColumn,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
|
|
@ -119,14 +129,6 @@ import {
|
|||
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
|
||||
|
||||
export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns";
|
||||
|
||||
type InboxCategoryFilter =
|
||||
| "everything"
|
||||
| "issues_i_touched"
|
||||
| "join_requests"
|
||||
| "approvals"
|
||||
| "failed_runs"
|
||||
| "alerts";
|
||||
type SectionKey =
|
||||
| "work_items"
|
||||
| "alerts";
|
||||
|
|
@ -141,8 +143,32 @@ type InboxGroupedSection = {
|
|||
label: string | null;
|
||||
displayItems: InboxWorkItem[];
|
||||
childrenByIssueId: Map<string, Issue[]>;
|
||||
isArchivedSearch: boolean;
|
||||
};
|
||||
|
||||
function buildGroupedInboxSections(
|
||||
items: InboxWorkItem[],
|
||||
groupBy: InboxWorkItemGroupBy,
|
||||
nestingEnabled: boolean,
|
||||
options?: { keyPrefix?: string; isArchivedSearch?: boolean },
|
||||
): InboxGroupedSection[] {
|
||||
const keyPrefix = options?.keyPrefix ?? "";
|
||||
const isArchivedSearch = options?.isArchivedSearch ?? false;
|
||||
return groupInboxWorkItems(items, groupBy).map((group) => {
|
||||
const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
|
||||
? buildInboxNesting(group.items)
|
||||
: { displayItems: group.items, childrenByIssueId: new Map<string, Issue[]>() };
|
||||
|
||||
return {
|
||||
key: `${keyPrefix}${group.key}`,
|
||||
label: group.label,
|
||||
displayItems: nestedGroup.displayItems,
|
||||
childrenByIssueId: nestedGroup.childrenByIssueId,
|
||||
isArchivedSearch,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
||||
|
|
@ -616,14 +642,15 @@ export function Inbox() {
|
|||
retry: false,
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||
const [issueFilters, setIssueFilters] = useState<IssueFilterState>(defaultIssueFilterState);
|
||||
const [filterPreferences, setFilterPreferences] = useState<InboxFilterPreferences>(
|
||||
() => loadInboxFilterPreferences(selectedCompanyId),
|
||||
);
|
||||
const [groupBy, setGroupBy] = useState<InboxWorkItemGroupBy>(() => loadInboxWorkItemGroupBy());
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
|
||||
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
|
||||
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
|
||||
const { allCategoryFilter, allApprovalFilter, issueFilters } = filterPreferences;
|
||||
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
||||
const tab: InboxTab =
|
||||
|
|
@ -681,6 +708,14 @@ export function Inbox() {
|
|||
setSearchQuery("");
|
||||
}, [tab]);
|
||||
|
||||
const previousSelectedCompanyIdRef = useRef<string | null>(selectedCompanyId);
|
||||
useEffect(() => {
|
||||
if (previousSelectedCompanyIdRef.current !== selectedCompanyId) {
|
||||
previousSelectedCompanyIdRef.current = selectedCompanyId;
|
||||
setFilterPreferences(loadInboxFilterPreferences(selectedCompanyId));
|
||||
}
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
const {
|
||||
data: approvals,
|
||||
isLoading: isApprovalsLoading,
|
||||
|
|
@ -754,6 +789,13 @@ export function Inbox() {
|
|||
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
||||
|
||||
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
|
||||
|
|
@ -852,13 +894,11 @@ export function Inbox() {
|
|||
);
|
||||
const liveIssueIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const run of heartbeatRuns ?? []) {
|
||||
if (run.status !== "running" && run.status !== "queued") continue;
|
||||
const issueId = readIssueIdFromRun(run);
|
||||
if (issueId) ids.add(issueId);
|
||||
for (const run of liveRuns ?? []) {
|
||||
if (run.issueId) ids.add(run.issueId);
|
||||
}
|
||||
return ids;
|
||||
}, [heartbeatRuns]);
|
||||
}, [liveRuns]);
|
||||
|
||||
const approvalsToRender = useMemo(() => {
|
||||
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
|
||||
|
|
@ -909,19 +949,12 @@ export function Inbox() {
|
|||
if (!q) return workItemsToRender;
|
||||
return workItemsToRender.filter((item) => {
|
||||
if (item.kind === "issue") {
|
||||
const issue = item.issue;
|
||||
if (issue.title.toLowerCase().includes(q)) return true;
|
||||
if (issue.identifier?.toLowerCase().includes(q)) return true;
|
||||
if (issue.description?.toLowerCase().includes(q)) return true;
|
||||
if (isolatedWorkspacesEnabled) {
|
||||
const workspaceName = resolveIssueWorkspaceName(issue, {
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
});
|
||||
if (workspaceName?.toLowerCase().includes(q)) return true;
|
||||
}
|
||||
return false;
|
||||
return matchesInboxIssueSearch(item.issue, q, {
|
||||
isolatedWorkspacesEnabled,
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
});
|
||||
}
|
||||
if (item.kind === "approval") {
|
||||
const a = item.approval;
|
||||
|
|
@ -963,6 +996,35 @@ export function Inbox() {
|
|||
projectWorkspaceById,
|
||||
]);
|
||||
|
||||
const archivedSearchIssues = useMemo(
|
||||
() =>
|
||||
tab === "mine"
|
||||
? getArchivedInboxSearchIssues({
|
||||
visibleIssues: visibleMineIssues,
|
||||
searchableIssues: visibleTouchedIssues,
|
||||
query: searchQuery,
|
||||
isolatedWorkspacesEnabled,
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
})
|
||||
: [],
|
||||
[
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
executionWorkspaceById,
|
||||
isolatedWorkspacesEnabled,
|
||||
projectWorkspaceById,
|
||||
searchQuery,
|
||||
tab,
|
||||
visibleMineIssues,
|
||||
visibleTouchedIssues,
|
||||
],
|
||||
);
|
||||
const archivedSearchIssueIds = useMemo(
|
||||
() => new Set(archivedSearchIssues.map((issue) => issue.id)),
|
||||
[archivedSearchIssues],
|
||||
);
|
||||
|
||||
// --- Parent-child nesting for inbox issues ---
|
||||
const [nestingPreferenceEnabled, setNestingPreferenceEnabled] = useState(() => loadInboxNesting());
|
||||
const nestingEnabled = resolveInboxNestingEnabled(nestingPreferenceEnabled, isMobile);
|
||||
|
|
@ -974,33 +1036,15 @@ export function Inbox() {
|
|||
});
|
||||
}, []);
|
||||
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(new Set());
|
||||
const groupedSections = useMemo<InboxGroupedSection[]>(() => {
|
||||
return groupInboxWorkItems(filteredWorkItems, groupBy).map((group) => {
|
||||
const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
|
||||
? buildInboxNesting(group.items)
|
||||
: { displayItems: group.items, childrenByIssueId: new Map<string, Issue[]>() };
|
||||
|
||||
return {
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
displayItems: nestedGroup.displayItems,
|
||||
childrenByIssueId: nestedGroup.childrenByIssueId,
|
||||
};
|
||||
});
|
||||
}, [filteredWorkItems, groupBy, nestingEnabled]);
|
||||
const nestedWorkItems = useMemo(
|
||||
() => groupedSections.flatMap((group) => group.displayItems),
|
||||
[groupedSections],
|
||||
);
|
||||
const childrenByIssueId = useMemo(() => {
|
||||
const merged = new Map<string, Issue[]>();
|
||||
for (const group of groupedSections) {
|
||||
for (const [issueId, children] of group.childrenByIssueId) {
|
||||
merged.set(issueId, children);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}, [groupedSections]);
|
||||
const groupedSections = useMemo<InboxGroupedSection[]>(() => [
|
||||
...buildGroupedInboxSections(filteredWorkItems, groupBy, nestingEnabled),
|
||||
...buildGroupedInboxSections(
|
||||
getInboxWorkItems({ issues: archivedSearchIssues, approvals: [] }),
|
||||
groupBy,
|
||||
nestingEnabled,
|
||||
{ keyPrefix: "archived-search:", isArchivedSearch: true },
|
||||
),
|
||||
], [archivedSearchIssues, filteredWorkItems, groupBy, nestingEnabled]);
|
||||
const totalVisibleWorkItems = useMemo(
|
||||
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
|
||||
[groupedSections],
|
||||
|
|
@ -1052,9 +1096,28 @@ export function Inbox() {
|
|||
}
|
||||
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
|
||||
}, [setIssueColumns, visibleIssueColumns]);
|
||||
const updateFilterPreferences = useCallback(
|
||||
(updater: (previous: InboxFilterPreferences) => InboxFilterPreferences) => {
|
||||
setFilterPreferences((previous) => {
|
||||
const next = updater(previous);
|
||||
saveInboxFilterPreferences(selectedCompanyId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[selectedCompanyId],
|
||||
);
|
||||
const updateIssueFilters = useCallback((patch: Partial<IssueFilterState>) => {
|
||||
setIssueFilters((previous) => ({ ...previous, ...patch }));
|
||||
}, []);
|
||||
updateFilterPreferences((previous) => ({
|
||||
...previous,
|
||||
issueFilters: { ...previous.issueFilters, ...patch },
|
||||
}));
|
||||
}, [updateFilterPreferences]);
|
||||
const updateAllCategoryFilter = useCallback((value: InboxCategoryFilter) => {
|
||||
updateFilterPreferences((previous) => ({ ...previous, allCategoryFilter: value }));
|
||||
}, [updateFilterPreferences]);
|
||||
const updateAllApprovalFilter = useCallback((value: InboxApprovalFilter) => {
|
||||
updateFilterPreferences((previous) => ({ ...previous, allApprovalFilter: value }));
|
||||
}, [updateFilterPreferences]);
|
||||
const updateGroupBy = useCallback((nextGroupBy: InboxWorkItemGroupBy) => {
|
||||
setGroupBy(nextGroupBy);
|
||||
saveInboxWorkItemGroupBy(nextGroupBy);
|
||||
|
|
@ -1325,6 +1388,7 @@ export function Inbox() {
|
|||
flatNavItems,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
archivedSearchIssueIds,
|
||||
archivingIssueIds,
|
||||
archivingNonIssueIds,
|
||||
fadingOutIssues,
|
||||
|
|
@ -1335,6 +1399,7 @@ export function Inbox() {
|
|||
flatNavItems,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
archivedSearchIssueIds,
|
||||
archivingIssueIds,
|
||||
archivingNonIssueIds,
|
||||
fadingOutIssues,
|
||||
|
|
@ -1415,10 +1480,12 @@ export function Inbox() {
|
|||
e.preventDefault();
|
||||
const { issue, item } = resolveNavEntry(st.selectedIndex);
|
||||
if (issue) {
|
||||
if (!st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
|
||||
if (!st.archivedSearchIssueIds.has(issue.id) && !st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
|
||||
} else if (item) {
|
||||
if (item.kind === "issue") {
|
||||
if (!st.archivingIssueIds.has(item.issue.id)) act.archiveIssue(item.issue.id);
|
||||
if (!st.archivedSearchIssueIds.has(item.issue.id) && !st.archivingIssueIds.has(item.issue.id)) {
|
||||
act.archiveIssue(item.issue.id);
|
||||
}
|
||||
} else {
|
||||
const key = getWorkItemKey(item);
|
||||
if (!st.archivingNonIssueIds.has(key)) act.archiveNonIssue(key);
|
||||
|
|
@ -1553,10 +1620,28 @@ export function Inbox() {
|
|||
placeholder="Search inbox…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (shouldBlurPageSearchOnEnter({
|
||||
key: e.key,
|
||||
isComposing: e.nativeEvent.isComposing,
|
||||
})) {
|
||||
e.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldBlurPageSearchOnEscape({
|
||||
key: e.key,
|
||||
isComposing: e.nativeEvent.isComposing,
|
||||
currentValue: e.currentTarget.value,
|
||||
})) {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="h-8 w-full pl-8 text-xs"
|
||||
data-page-search-target="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
|
|
@ -1582,7 +1667,25 @@ export function Inbox() {
|
|||
placeholder="Search inbox…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (shouldBlurPageSearchOnEnter({
|
||||
key: e.key,
|
||||
isComposing: e.nativeEvent.isComposing,
|
||||
})) {
|
||||
e.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldBlurPageSearchOnEscape({
|
||||
key: e.key,
|
||||
isComposing: e.nativeEvent.isComposing,
|
||||
currentValue: e.currentTarget.value,
|
||||
})) {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="h-8 w-[220px] pl-8 text-xs"
|
||||
data-page-search-target="true"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -1604,17 +1707,20 @@ export function Inbox() {
|
|||
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||
currentUserId={currentUserId}
|
||||
enableRoutineVisibilityFilter
|
||||
buttonVariant="outline"
|
||||
iconOnly
|
||||
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace" && w.status === "active").map((w) => ({ id: w.id, name: w.name })) : undefined}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("h-8 shrink-0 text-xs", groupBy !== "none" && "bg-accent")}
|
||||
size="icon"
|
||||
className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")}
|
||||
title="Group"
|
||||
>
|
||||
<Layers className="mr-1.5 h-3.5 w-3.5" />
|
||||
Group
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-40 p-2">
|
||||
|
|
@ -1645,6 +1751,7 @@ export function Inbox() {
|
|||
onToggleColumn={toggleIssueColumn}
|
||||
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
||||
title="Choose which inbox columns stay visible"
|
||||
iconOnly
|
||||
/>
|
||||
{canMarkAllRead && (
|
||||
<>
|
||||
|
|
@ -1691,7 +1798,7 @@ export function Inbox() {
|
|||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={allCategoryFilter}
|
||||
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
||||
onValueChange={(value) => updateAllCategoryFilter(value as InboxCategoryFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[170px] text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
|
|
@ -1709,7 +1816,7 @@ export function Inbox() {
|
|||
{showApprovalsCategory && (
|
||||
<Select
|
||||
value={allApprovalFilter}
|
||||
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
|
||||
onValueChange={(value) => updateAllApprovalFilter(value as InboxApprovalFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[170px] text-xs">
|
||||
<SelectValue placeholder="Approval status" />
|
||||
|
|
@ -1783,6 +1890,7 @@ export function Inbox() {
|
|||
isExpanded = false,
|
||||
childCount = 0,
|
||||
collapseParentId = null,
|
||||
allowArchive = canArchiveFromTab,
|
||||
}: {
|
||||
issue: Issue;
|
||||
depth: number;
|
||||
|
|
@ -1791,6 +1899,7 @@ export function Inbox() {
|
|||
isExpanded?: boolean;
|
||||
childCount?: number;
|
||||
collapseParentId?: string | null;
|
||||
allowArchive?: boolean;
|
||||
}) => {
|
||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||
const isFading = fadingOutIssues.has(issue.id);
|
||||
|
|
@ -1857,7 +1966,7 @@ export function Inbox() {
|
|||
}
|
||||
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
||||
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||
onArchive={canArchiveFromTab ? () => archiveIssueMutation.mutate(issue.id) : undefined}
|
||||
onArchive={allowArchive ? () => archiveIssueMutation.mutate(issue.id) : undefined}
|
||||
archiveDisabled={isArchiving || archiveIssueMutation.isPending}
|
||||
desktopTrailing={
|
||||
visibleTrailingIssueColumns.length > 0 ? (
|
||||
|
|
@ -1885,6 +1994,20 @@ export function Inbox() {
|
|||
let previousTimestamp = Number.POSITIVE_INFINITY;
|
||||
return groupedSections.flatMap((group, groupIndex) => {
|
||||
const elements: ReactNode[] = [];
|
||||
if (group.isArchivedSearch && (groupIndex === 0 || !groupedSections[groupIndex - 1]?.isArchivedSearch)) {
|
||||
elements.push(
|
||||
<div
|
||||
key="archived-search-divider"
|
||||
className="flex items-center gap-3 border-y border-border/70 bg-muted/30 px-4 py-2"
|
||||
>
|
||||
<div className="h-px flex-1 bg-border/80" />
|
||||
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Archived
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border/80" />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
if (group.label) {
|
||||
elements.push(
|
||||
<div
|
||||
|
|
@ -2044,6 +2167,7 @@ export function Inbox() {
|
|||
const childIssues = group.childrenByIssueId.get(issue.id) ?? [];
|
||||
const hasChildren = childIssues.length > 0;
|
||||
const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id);
|
||||
const canArchiveIssue = canArchiveFromTab && !group.isArchivedSearch;
|
||||
const parentRow = renderInboxIssue({
|
||||
issue,
|
||||
depth: 0,
|
||||
|
|
@ -2052,9 +2176,10 @@ export function Inbox() {
|
|||
isExpanded,
|
||||
childCount: childIssues.length,
|
||||
collapseParentId: issue.id,
|
||||
allowArchive: canArchiveIssue,
|
||||
});
|
||||
|
||||
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
|
||||
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveIssue ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${issue.id}`}
|
||||
selected={isSelected}
|
||||
|
|
@ -2073,6 +2198,7 @@ export function Inbox() {
|
|||
issue: child,
|
||||
depth: 1,
|
||||
selected: isChildSelected,
|
||||
allowArchive: canArchiveIssue,
|
||||
});
|
||||
const isChildArchiving = archivingIssueIds.has(child.id);
|
||||
elements.push(
|
||||
|
|
@ -2082,7 +2208,7 @@ export function Inbox() {
|
|||
className="relative"
|
||||
onClick={() => setSelectedIndex(childNavIdx)}
|
||||
>
|
||||
{canArchiveFromTab ? (
|
||||
{canArchiveIssue ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${child.id}`}
|
||||
selected={isChildSelected}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { Link, useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
|
||||
import { routinesApi } from "../api/routines";
|
||||
import { agentsApi } from "../api/agents";
|
||||
|
|
@ -182,7 +182,7 @@ function RoutineListRow({
|
|||
agentById,
|
||||
runningRoutineId,
|
||||
statusMutationRoutineId,
|
||||
onNavigate,
|
||||
href,
|
||||
onRunNow,
|
||||
onToggleEnabled,
|
||||
onToggleArchived,
|
||||
|
|
@ -192,7 +192,7 @@ function RoutineListRow({
|
|||
agentById: Map<string, { name: string; icon?: string | null }>;
|
||||
runningRoutineId: string | null;
|
||||
statusMutationRoutineId: string | null;
|
||||
onNavigate: (routineId: string) => void;
|
||||
href: string;
|
||||
onRunNow: (routine: RoutineListItem) => void;
|
||||
onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void;
|
||||
onToggleArchived: (routine: RoutineListItem) => void;
|
||||
|
|
@ -205,9 +205,9 @@ function RoutineListRow({
|
|||
const isDraft = !isArchived && !routine.assigneeAgentId;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex cursor-pointer flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center"
|
||||
onClick={() => onNavigate(routine.id)}
|
||||
<Link
|
||||
to={href}
|
||||
className="group flex flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center no-underline text-inherit"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
|
@ -237,7 +237,7 @@ function RoutineListRow({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch
|
||||
size="lg"
|
||||
|
|
@ -258,8 +258,8 @@ function RoutineListRow({
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onNavigate(routine.id)}>
|
||||
Edit
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={href}>Edit</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={runningRoutineId === routine.id || isArchived}
|
||||
|
|
@ -283,7 +283,7 @@ function RoutineListRow({
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -566,9 +566,8 @@ export function Routines() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Routines
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">Beta</span>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Recurring work definitions that materialize into auditable execution issues.
|
||||
|
|
@ -953,7 +952,7 @@ export function Routines() {
|
|||
agentById={agentById}
|
||||
runningRoutineId={runningRoutineId}
|
||||
statusMutationRoutineId={statusMutationRoutineId}
|
||||
onNavigate={(routineId) => navigate(`/routines/${routineId}`)}
|
||||
href={`/routines/${routine.id}`}
|
||||
onRunNow={handleRunNow}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onToggleArchived={handleToggleArchived}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue