chore: merge v3-develop into agent/PM-291

This commit is contained in:
YANG QIA
2026-10-12 20:10:31 -04:00
84 changed files with 2893 additions and 303 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@nocobase/studio': patch
'@nocobase/app-plugin-projects': patch
---
Pause agent work after an owner transfer until the new owner accepts. Add owner acceptance and rejection in issue pages and inbox decisions, with transfer activities and localized waiting reasons.
+6
View File
@@ -0,0 +1,6 @@
---
'@nocobase/app-template-default': patch
'@nocobase/app-template-examples': patch
---
Refine authentication pages with a cohesive dark palette, quieter form controls, and a simplified brand panel while preserving the application-wide theme.
+5
View File
@@ -0,0 +1,5 @@
---
"@nocobase/studio": patch
---
Make the inbox the default landing page: `/` redirects signed-in users to `/inbox`, preserving query and fragment. Order the sidebar as My tasks, Inbox and Dashboard, remove the Home entry and the header inbox button, and retain the live inbox count in the sidebar. Move the chat composer to `/chat` and redirect legacy `/home` links there.
+1 -1
View File
@@ -78,7 +78,7 @@ Layouts own breadcrumb route context and render the current page's trail in the
`client/layouts/components/layout-header.tsx` is a presentation container accepting children. The sidebar is built from the shadcn Sidebar primitives (`client/components/ui/sidebar.tsx`): `app-sidebar.tsx` provides the provider, the sidebar shell (brand, footer, rail), and the header toggle; `navigation-menu.tsx` draws a route tree as groups, menu buttons and collapsible sub-menus; `settings-navigation.tsx` draws the `/config` pages the same way. The App layout owns menus, permissions and redirects. Desktop icon mode (`collapsible='icon'`) uses tooltips for leaf labels and hover/focus popovers for entries with children, preserving filtered navigation, parent-page links and nested groups; on a phone the sidebar is the primitives' sheet, which closes when an entry is chosen. Keep this behavior aligned across layouts. Desktop collapse state is shared through `useSidebarPreference` at `nocobase:sidebar:collapsed` across applications on the same origin; mobile visibility stays local to each layout.
The framework has no back-office settings surface: `AppRouter` renders no `/settings/*` pages, so App routes plugins register under `/settings` are unreachable and every `/settings` URL lands on the home page. Studio's settings are its own `/config` (the sidebar's Settings entry) and the person's `/account` (the account menu); a plugin settings page Studio needs becomes a `/config` tab over the plugin's API. The header has no settings entry. Its left side holds the sidebar toggle and the current page's breadcrumb: the route trail by default (routes declaring `breadcrumb`), or the trail a page declares with `usePageBreadcrumb` from `@nocobase/app-client` when it names records — a project, an issue and its parent, a plan, a role, an agent, a skill, a release App or environment. Pages render no breadcrumb or back link of their own; a back control stays only where it carries state the trail cannot, such as the knowledge view's document path, the inbox's narrow-screen return to its list, or the recovery action of a page that failed to load. Its right side holds the inbox button (`client/inbox/header-button.tsx`), with the count of decisions waiting on the viewer, then the Agent textarea and the account menu, whose Theme and Language submenus are quick switches over the same values the Preferences page (`/account/preferences`) keeps. The standalone pages (sign-in and the like) have no account menu and keep the appearance popover (`client/theme/theme-settings.tsx`).
The framework has no back-office settings surface: `AppRouter` renders no `/settings/*` pages, so App routes plugins register under `/settings` are unreachable and every `/settings` URL lands on the inbox. Studio's settings are its own `/config` (the sidebar's Settings entry) and the person's `/account` (the account menu); a plugin settings page Studio needs becomes a `/config` tab over the plugin's API. The header has no settings entry. Its left side holds the sidebar toggle and the current page's breadcrumb: the route trail by default (routes declaring `breadcrumb`), or the trail a page declares with `usePageBreadcrumb` from `@nocobase/app-client` when it names records — a project, an issue and its parent, a plan, a role, an agent, a skill, a release App or environment. Pages render no breadcrumb or back link of their own; a back control stays only where it carries state the trail cannot, such as the knowledge view's document path, the inbox's narrow-screen return to its list, or the recovery action of a page that failed to load. Its right side holds the Agent textarea and the account menu, whose Theme and Language submenus are quick switches over the same values the Preferences page (`/account/preferences`) keeps. On phones, the Agent textarea occupies a second header row; there is no floating launcher. The sidebar starts with My tasks, Inbox and Dashboard, followed by My follow-ups, with no Home entry. `/` redirects to `/inbox`, preserving query and fragment. The chat composer is `/chat`, reached from chat actions rather than the menu; legacy `/home` links redirect there with query and fragment preserved. `client/inbox/navigation.ts` keeps inbox counts, refresh subscriptions, browser title and chimes mounted once in the App layout, including on settings pages. The route-keyed `NavigationMenu` decoration supplies the live badge, full accessible label and tooltip because route navigation declarations are static. The badge remains visible in desktop icon mode and the mobile drawer. The standalone pages (sign-in and the like) have no account menu and keep the appearance popover (`client/theme/theme-settings.tsx`).
`client/routing/`, `client/layouts/`, `client/theme/`, the server entry points, the `package.json` scripts that call `nocobase`, and the tsconfigs are the scaffolding the template provides. It is still this application's own source — it shipped to the user and they may change it — but it is the part the template evolves, so an edit there is what a future upgrade has to reconcile.
+1 -1
View File
@@ -21,7 +21,7 @@ pnpm dev
pnpm lint && pnpm typecheck && pnpm test && pnpm openapi:check && pnpm cli-reference:check
```
Type-aware lint and typecheck load the framework's sources too; give Node a larger heap if they run out of memory (`NODE_OPTIONS=--max-old-space-size=6144`).
Type-aware lint and typecheck load the framework's sources too. The lint scripts allow a 4 GiB Node heap; give typecheck a larger heap if it runs out of memory (`NODE_OPTIONS=--max-old-space-size=6144`).
Studio's database migrations follow `AGENTS.md`: once a migration has been merged it is never edited, and every later change is a new migration, because installations keep their data across updates.
@@ -92,11 +92,9 @@ export function InboxButton({
<span
className={cn(
// Anchored to the corner and pushed a third of its size out, whatever its width.
'absolute top-0 right-0 flex h-4.5 min-w-4.5 translate-x-1/3 -translate-y-1/3 items-center justify-center rounded-full px-1 leading-none font-semibold tabular-nums ring-2 ring-background',
// "99+" in smaller type, so the badge stays a pill rather than covering the icon.
badge.text.length > 2 ? 'text-[9px]' : 'text-[11px]',
'absolute top-0 right-0 flex h-5 min-w-5 translate-x-1/3 -translate-y-1/3 items-center justify-center rounded-full px-1 text-xs leading-none font-semibold tabular-nums ring-2 ring-background',
badge.kind === 'decisions'
? 'bg-amber-500 text-white'
? 'bg-inbox-pending text-inbox-pending-foreground'
: 'bg-primary text-primary-foreground',
)}
data-testid={`${idPrefix}-badge`}
@@ -40,7 +40,10 @@ import {
} from '../../shared/project-manager.js';
import { useIssuePullRequests, usePullRequestMark } from '../git/api.js';
import { PR_ACTIVITY_LABELS } from '../git/lib.js';
import { actionLabel } from '../issues/detail/activity-labels.js';
import {
actionLabel,
isOwnerHandoffActivity,
} from '../issues/detail/activity-labels.js';
import {
issueProgressKey,
progressSentence,
@@ -526,7 +529,9 @@ function describe(
const activity = event.activity;
if (!activity) return { what: '', detail: null };
return {
what: projects(actionLabel(activity.action, activity.details), {
what: (isOwnerHandoffActivity(activity.action, activity.details)
? t
: projects)(actionLabel(activity.action, activity.details), {
name: text(activity.details.name) ?? projects('common.unknown'),
}),
detail: null,
@@ -8,6 +8,7 @@ import { Link } from 'react-router';
import { Skeleton } from '@/components/ui/skeleton';
import { RecentActivity } from './recent-activity.js';
import { OwnerHandoffActions } from '../../issues/detail/owner-handoff.js';
import type { InboxPartProps } from '@/extensions/nocobase-inbox/registry';
import type { ProjectsModel } from './projects.js';
import { useParamsOf } from './projects-wording.js';
@@ -30,6 +31,12 @@ export function ProjectsBody({
{params.excerpt}
</blockquote>
) : null}
{entry.notice?.type === 'owner_handoff' && issueId ? (
<OwnerHandoffActions
issueId={issueId}
notificationKey={params.notificationKey}
/>
) : null}
{entry.notice?.type === 'comment_runs_limited' && issueId ? (
<Link
to={`/issues/${encodeURIComponent(detail.data?.identifier ?? issueId)}`}
@@ -179,6 +179,10 @@ export function useEntryText(): (
let sentence: string | null = null;
switch (notice.type) {
case 'owner_handoff':
case 'owner_handoff_rejected':
sentence = t(`inbox.body.${notice.type}`);
break;
case 'status_changed': {
const from = status(params.from);
if (from && to)
@@ -43,6 +43,8 @@ import {
/** One icon per type, so a card is recognisable before it is read. */
const TYPE_ICON: Readonly<Record<string, LucideIcon>> = {
owner_handoff: UserCheckIcon,
owner_handoff_rejected: CircleXIcon,
approval_requested: ShieldCheckIcon,
approval_decided: ShieldCheckIcon,
approval_stale: ShieldAlertIcon,
+1 -1
View File
@@ -1,4 +1,4 @@
/** The inbox's counts and refresh for the header and the dashboard, over the in-app plugin's hooks and Studio's source. */
/** The inbox's counts and refresh for the navigation and dashboard, over the in-app plugin's hooks and Studio's source. */
import {
useInboxRefresh as useInAppInboxRefresh,
useInboxUnreadCount,
@@ -1,8 +1,8 @@
import { useTranslation } from '@nocobase/i18n/client';
import { type ReactElement, useEffect, useRef } from 'react';
import { useEffect, useRef } from 'react';
import { inboxBadge, useDocumentTitleBadge } from '@/components/inbox-badge';
import { InboxButton } from '@/components/inbox-button';
import type { InboxBadge } from '@/components/inbox-badge';
import { armInboxChime, playInboxChime, useInboxChime } from './chime.js';
import {
@@ -13,15 +13,19 @@ import {
import { usePendingFeeds } from './use-registry.js';
/**
* The header's inbox button (the UI Library's `InboxButton`), with the number of decisions waiting on the viewer.
* The application layout owns the inbox subscription and reminders, even while the menu is hidden or replaced by
* settings navigation. Route navigation only describes static entries, so it receives the badge as presentation data.
*
* The amber count is what still needs the viewer's action, not what is unread: a decision that was read but not handled
* keeps counting until it is resolved. With no decision waiting, a count in the primary color says how many items are
* still unread; why the two never add up is `inboxBadge`'s. Whichever count shows prefixes the browser tab title
* (`(3) Studio`), and a rising decision count rings the chime unless the viewer turned it off. The header is mounted once
* per layout, so the button owns both.
* (`(3) Studio`), and a rising decision count rings the chime unless the viewer turned it off.
*/
export function InboxHeaderButton(): ReactElement {
export function useInboxNavigation(): {
readonly badge: InboxBadge;
readonly label: string;
readonly hint: string;
} {
const { t } = useTranslation();
useInboxRefresh();
const pending = usePendingDecisions();
@@ -49,17 +53,23 @@ export function InboxHeaderButton(): ReactElement {
previousRef.current = next;
}, [decisions, plans, chime]);
return (
<InboxButton
badge={badge}
idPrefix='studio-inbox'
labels={{
title: t('inbox.title'),
pending: (value) => t('inbox.headerPending', { count: value }),
unread: (value) => t('inbox.headerUnread', { count: value }),
pendingHint: (value) => t('inbox.pendingBadgeHint', { count: value }),
unreadHint: (value) => t('inbox.unreadBadgeHint', { count: value }),
}}
/>
);
return {
badge,
label: !badge
? t('inbox.title')
: t(
badge.kind === 'decisions'
? 'inbox.headerPending'
: 'inbox.headerUnread',
{ count: badge.count },
),
hint: !badge
? t('inbox.title')
: t(
badge.kind === 'decisions'
? 'inbox.pendingBadgeHint'
: 'inbox.unreadBadgeHint',
{ count: badge.count },
),
};
}
@@ -6,6 +6,9 @@ import type { Activity } from '@nocobase/app-plugin-projects/shared/issues';
/** The server's activity actions and the keys of their wording. */
const ACTION_LABELS: Readonly<Record<string, string>> = {
studio_owner_transferred: 'ownerTransferred',
studio_owner_accepted: 'ownerAccepted',
studio_owner_rejected: 'ownerRejected',
issue_created: 'created',
issue_deleted: 'deleted',
issue_restored: 'restored',
@@ -61,10 +64,23 @@ export function actionLabel(
key = 'stageActionBlocked';
else if (action === 'work_skipped')
key =
reason === 'dormant'
? 'workDormant'
: reason === 'limited'
? 'workLimited'
: 'workBlocked';
reason === 'waitingOwner'
? 'waitingOwner'
: reason === 'dormant'
? 'workDormant'
: reason === 'limited'
? 'workLimited'
: 'workBlocked';
return `activity.actions.${key}`;
}
/** Handoff activities use Studio's wording rather than the projects plugin's. */
export function isOwnerHandoffActivity(
action: string,
details: Activity['details'],
): boolean {
return (
action.startsWith('studio_owner_') ||
(action === 'work_skipped' && details.reason === 'waitingOwner')
);
}
@@ -25,7 +25,7 @@ import {
import { PR_ACTIVITY_LABELS } from '../../git/lib.js';
import type { Translate } from './labels.js';
import { actionLabel } from './activity-labels.js';
import { actionLabel, isOwnerHandoffActivity } from './activity-labels.js';
import { usePlanLinkTo } from './plan-links.js';
import { StatusBadge } from './status-badge.js';
@@ -254,7 +254,9 @@ export function ActivityRow({
text(activity.details.name) ??
t('common.unknown'),
})
: t(actionLabel(activity.action, activity.details), {
: (isOwnerHandoffActivity(activity.action, activity.details)
? studioT
: t)(actionLabel(activity.action, activity.details), {
name: text(activity.details.name) ?? t('common.unknown'),
})}
</span>
@@ -21,6 +21,7 @@ import { Spinner } from '@/components/ui/spinner';
import type { IssueProgress } from '../../../shared/project-manager.js';
import { useNotify } from '../../access/notify.js';
import { issueProgressKey, progressSentence } from './progress-text.js';
import { OwnerHandoffActions } from './owner-handoff.js';
/** Refetches on its own too: a pull request's checks change with no issue change. */
const PROGRESS_POLL_MS = 15_000;
@@ -74,7 +75,10 @@ export function IssueProgressLine({
});
const data = progress.data;
const canStart = canEdit && issue.statusKey === BACKLOG_STATUS;
const canStart =
canEdit &&
issue.statusKey === BACKLOG_STATUS &&
data?.waiting?.reason !== 'waitingOwner';
const canHandOver =
canEdit && data?.handOver === true && issue.statusKey !== BACKLOG_STATUS;
return (
@@ -117,6 +121,7 @@ export function IssueProgressLine({
</p>
) : null}
</div>
<OwnerHandoffActions issueId={issue.id} />
{canStart || canHandOver ? (
<div className='flex shrink-0 flex-wrap gap-2'>
{canStart ? (
@@ -0,0 +1,172 @@
import { ApiClientError, useApiClient } from '@nocobase/app-client';
import { useTranslation } from '@nocobase/i18n/client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { Skeleton } from '@/components/ui/skeleton';
import { inboxKeys } from '@nocobase/app-plugin-notification-in-app/client/inbox';
import { useNotify } from '../../access/notify.js';
const ownerHandoffKey = (issueId: string) =>
['studio', 'ownerHandoff', issueId] as const;
interface HandoffView {
readonly handoff: {
readonly waiting: boolean;
readonly notificationKey: string;
readonly rejectedAt: string | null;
readonly ownerUserId: string;
} | null;
readonly canDecide: boolean;
readonly canClaim: boolean;
}
/** Shared by the issue page and its inbox decision. An old inbox item never borrows the current decision. */
export function OwnerHandoffActions({
issueId,
notificationKey,
}: {
readonly issueId: string;
readonly notificationKey?: string;
}) {
const { t } = useTranslation();
const api = useApiClient();
const queries = useQueryClient();
const notify = useNotify();
const handoff = useQuery({
queryKey: ownerHandoffKey(issueId),
queryFn: async () =>
(
await api.request<{ data: HandoffView }>({
path: `issueOwnerHandoffs/${encodeURIComponent(issueId)}`,
})
).data,
refetchInterval: 15_000,
});
const decide = useMutation({
mutationFn: (decision: 'accept' | 'reject') =>
api.request({
method: 'POST',
path: `issueOwnerHandoffs/${encodeURIComponent(issueId)}/${decision}`,
json: {
notificationKey:
notificationKey ?? handoff.data?.handoff?.notificationKey,
},
}),
onSuccess: (_data, decision) =>
notify.success(
t(`ownerHandoff.${decision === 'accept' ? 'accepted' : 'rejected'}`),
),
onError: (error) =>
notify.error(
null,
t(
error instanceof ApiClientError && error.status === 409
? 'ownerHandoff.stale'
: 'ownerHandoff.failed',
),
),
onSettled: async () => {
await Promise.all(
[
ownerHandoffKey(issueId),
['studio', 'issueProgress', issueId],
['pm', 'issue'],
inboxKeys.all,
].map((queryKey) => queries.invalidateQueries({ queryKey })),
);
},
});
const claim = useMutation({
mutationFn: async () => {
const issue = await api.request<{ data: { revision: number } }>({
path: `projects/issues/${encodeURIComponent(issueId)}`,
});
return api.request({
method: 'PATCH',
path: `projects/issues/${encodeURIComponent(issueId)}`,
json: {
revision: issue.data.revision,
ownerUserId: handoff.data?.handoff?.ownerUserId,
},
});
},
onSuccess: () => notify.success(t('ownerHandoff.accepted')),
onError: () => notify.error(null, t('ownerHandoff.failed')),
onSettled: async () => {
await Promise.all(
[
ownerHandoffKey(issueId),
['studio', 'issueProgress', issueId],
['pm', 'issue'],
inboxKeys.all,
].map((queryKey) => queries.invalidateQueries({ queryKey })),
);
},
});
if (handoff.isPending)
return notificationKey ? <Skeleton className='h-8 w-40' /> : null;
if (handoff.isError)
return (
<p className='text-sm text-muted-foreground'>
{t('ownerHandoff.loadFailed')}
</p>
);
const current = handoff.data?.handoff;
if (
notificationKey &&
(!current?.waiting ||
current.notificationKey !== notificationKey ||
current.rejectedAt)
)
return (
<p className='text-sm text-muted-foreground'>{t('ownerHandoff.stale')}</p>
);
if (!current?.waiting) return null;
if (current.rejectedAt)
return (
<div className='space-y-2'>
<p className='text-sm text-muted-foreground'>
{t('ownerHandoff.rejectedWaiting')}
</p>
{handoff.data?.canClaim ? (
<Button
size='sm'
disabled={claim.isPending}
onClick={() => claim.mutate()}
>
{claim.isPending ? <Spinner data-icon='inline-start' /> : null}
{t('ownerHandoff.claim')}
</Button>
) : null}
</div>
);
if (!handoff.data?.canDecide) return null;
return (
<div
className='flex flex-wrap items-center gap-2'
aria-label={t('ownerHandoff.title')}
>
<Button
size='sm'
disabled={decide.isPending}
onClick={() => decide.mutate('accept')}
>
{decide.isPending && decide.variables === 'accept' ? (
<Spinner data-icon='inline-start' />
) : null}
{t('ownerHandoff.accept')}
</Button>
<Button
size='sm'
variant='outline'
disabled={decide.isPending}
onClick={() => decide.mutate('reject')}
>
{decide.isPending && decide.variables === 'reject' ? (
<Spinner data-icon='inline-start' />
) : null}
{t('ownerHandoff.reject')}
</Button>
</div>
);
}
@@ -12,6 +12,7 @@ import { RouteTreeProvider } from '../routing/route-context.js';
import { ChatPanel } from '@/extensions/nocobase-agent-chat/chat-panel';
import { HeaderChat } from '../agents/header-chat.js';
import { Breadcrumbs } from '@/components/breadcrumbs';
import { useInboxNavigation } from '../inbox/navigation.js';
import { useTranslation } from '@nocobase/i18n/client';
import { LayoutHeader } from './components/layout-header.js';
@@ -43,6 +44,7 @@ export function AppLayout({ routes }: AppLayoutProps): ReactElement {
useSyncServerLocale();
// The person's language and theme follow them from the server (`account/preferences-sync.ts`).
useUserPreferencesSync();
const inbox = useInboxNavigation();
const { t } = useTranslation();
const { items: menuItems, denied } = useRouteNavigation(routes);
@@ -74,6 +76,7 @@ export function AppLayout({ routes }: AppLayoutProps): ReactElement {
})}
sections
selectedKey={selectedKey}
decorations={{ inbox }}
/>
)}
</AppSidebar>
@@ -2,7 +2,6 @@ import type { ReactElement } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip';
import { HeaderChat } from '../../agents/header-chat.js';
import { InboxHeaderButton } from '../../inbox/header-button.js';
import { UserMenu } from './user-menu.js';
/** Keep header entries visible on their destination pages so navigation stays consistent across surfaces. */
@@ -10,10 +9,6 @@ export function HeaderActions(): ReactElement {
return (
<TooltipProvider>
<div className='flex shrink-0 items-center gap-2'>
{/* The inbox, with the count of decisions waiting on the viewer. Studio has no header settings entry: its
settings are the sidebar's (`/config`), and the framework's back-office settings are not mounted
(`routing/app-router.tsx`). */}
<InboxHeaderButton />
{/* Desktop editor; the layout places the mobile editor on a second row. */}
<HeaderChat />
{/* Theme and language are quick choices in the account menu; Preferences holds them all. */}
@@ -2,6 +2,8 @@ import { useTranslation } from '@nocobase/i18n/client';
import { ChevronRight } from 'lucide-react';
import { useRef, useState, type ReactElement } from 'react';
import { Link } from 'react-router';
import { cn } from 'cn';
import type { InboxBadge } from '@/components/inbox-badge';
import {
Collapsible,
@@ -39,6 +41,13 @@ interface Section {
readonly items: readonly RouteNavigationItem[];
}
/** Live presentation for a route entry; links, selection and permissions remain owned by route navigation. */
export interface NavigationDecoration {
readonly badge: InboxBadge;
readonly label: string;
readonly hint: string;
}
/**
* A route tree as the sidebar's menu, drawn with the shadcn Sidebar primitives like the settings navigation.
*
@@ -53,11 +62,13 @@ export function NavigationMenu({
label,
sections: asSections = false,
selectedKey,
decorations,
}: {
readonly items: readonly RouteNavigationItem[];
readonly label: string;
readonly sections?: boolean;
readonly selectedKey: string | undefined;
readonly decorations?: Readonly<Record<string, NavigationDecoration>>;
}): ReactElement {
const sections = asSections
? toSections(items)
@@ -85,6 +96,7 @@ export function NavigationMenu({
key={routeKey(item.route)}
item={item}
selectedKey={selectedKey}
decoration={decorations?.[item.route.name]}
/>
))}
</SidebarMenu>
@@ -157,6 +169,7 @@ interface ItemProps {
/** Rendered inside the icon mode's popover, where tooltips and the icon mode's popovers do not apply. */
readonly inPopover?: boolean;
readonly onNavigate?: () => void;
readonly decoration?: NavigationDecoration;
}
/** One top-level entry of a menu: a link, a collapsible sub-menu, or in the icon mode a popover of its children. */
@@ -165,6 +178,7 @@ function NavigationItem({
selectedKey,
inPopover = false,
onNavigate,
decoration,
}: ItemProps): ReactElement | null {
const { state, isMobile } = useSidebar();
const closeSheet = useCloseOnNavigate();
@@ -193,6 +207,7 @@ function NavigationItem({
to={item.route.path}
onClick={navigate}
aria-current={isSelected ? 'page' : undefined}
aria-label={decoration?.label}
/>
) : undefined;
@@ -202,10 +217,37 @@ function NavigationItem({
<SidebarMenuButton
render={link}
isActive={isSelected}
tooltip={inPopover ? undefined : label}
tooltip={inPopover ? undefined : (decoration?.hint ?? label)}
className={decoration ? 'relative overflow-visible' : undefined}
>
{Icon ? <Icon /> : null}
<span>{label}</span>
<span
className={
state === 'collapsed' && !isMobile && !inPopover
? 'sr-only'
: 'truncate'
}
>
{label}
</span>
{decoration?.badge ? (
<span
aria-hidden='true'
data-testid='studio-inbox-badge'
data-kind={decoration.badge.kind}
className={cn(
'flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full px-1 text-xs leading-none font-semibold tabular-nums',
state === 'collapsed' && !isMobile && !inPopover
? 'absolute -top-0.5 -right-0.5 ring-2 ring-sidebar'
: 'ml-auto',
decoration.badge.kind === 'decisions'
? 'bg-inbox-pending text-inbox-pending-foreground'
: 'bg-primary text-primary-foreground',
)}
>
{decoration.badge.text}
</span>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
);
@@ -13,6 +13,32 @@ import inboxEnUS from '@/extensions/nocobase-inbox/locales/en-US';
import planCardEnUS from '@/extensions/nocobase-plan-card/locales/en-US';
const enUS = {
'ownerHandoff.title': 'Owner handoff',
'ownerHandoff.accept': 'Take over',
'ownerHandoff.claim': 'Claim',
'ownerHandoff.reject': 'Reject',
'ownerHandoff.accepted': 'Handoff accepted',
'ownerHandoff.rejected': 'Handoff rejected; agent work remains paused',
'ownerHandoff.rejectedWaiting':
'Handoff rejected. Waiting for a new assignment or a claim by the owner.',
'ownerHandoff.stale':
'This handoff is no longer awaiting a decision. Refresh the issue.',
'ownerHandoff.failed':
'Could not decide this handoff. Check your permission and refresh the issue.',
'ownerHandoff.loadFailed': 'Could not load the owner handoff.',
'activity.actions.ownerTransferred': 'transferred responsibility',
'activity.actions.ownerAccepted': 'accepted responsibility',
'activity.actions.ownerRejected': 'rejected the handoff',
'activity.actions.waitingOwner':
'skipped agent work: waiting for the new owner to take over',
'inbox.types.owner_handoff': 'Owner handoff',
'inbox.types.owner_handoff_rejected': 'Owner handoff rejected',
'inbox.text.owner_handoff': 'Take over {{identifier}}',
'inbox.text.owner_handoff_rejected': 'Owner handoff rejected: {{identifier}}',
'inbox.body.owner_handoff':
'Accept or reject this handoff. Agent work is paused until the new owner accepts.',
'inbox.body.owner_handoff_rejected':
'The new owner declined. The owner stays assigned and agent work remains paused.',
'issueRuns.model': 'Model: {{model}}',
'issueRuns.configuredModel': 'Configured model: {{model}}',
'issueRuns.modelUnknown': 'Model not reported',
@@ -1083,6 +1109,7 @@ const enUS = {
comment: 'Comment',
statusChange: 'Status change',
ownerChanged: 'Owner change',
ownerAccepted: 'Owner accepted',
projectChanged: 'Moved to another project',
unblocked: 'Unblocked',
subtasksFinished: 'Sub-issues done',
@@ -1391,6 +1418,7 @@ const enUS = {
issueStart: { title: 'Start now?', later: "Don't start now", start: 'Start' },
issueProgress: {
waiting: {
waitingOwner: 'Waiting for {{owner}} to take over',
noOwner: 'Waiting for a valid issue owner',
notConfigured:
'{{agent}} waits: no preferred or fallback model is configured',
@@ -13,6 +13,28 @@ import inboxZhCN from '@/extensions/nocobase-inbox/locales/zh-CN';
import planCardZhCN from '@/extensions/nocobase-plan-card/locales/zh-CN';
const zhCN: AppResource = {
'ownerHandoff.title': '负责人接手',
'ownerHandoff.accept': '接手',
'ownerHandoff.claim': '认领',
'ownerHandoff.reject': '拒绝',
'ownerHandoff.accepted': '已接手',
'ownerHandoff.rejected': '已拒绝接手,agent 运行保持暂停',
'ownerHandoff.rejectedWaiting': '已拒绝接手,等待重新转交或负责人自行认领。',
'ownerHandoff.stale': '此接手请求已失效,请刷新任务。',
'ownerHandoff.failed': '未能处理接手请求,请检查权限并刷新任务。',
'ownerHandoff.loadFailed': '未能加载接手请求。',
'activity.actions.ownerTransferred': '转交了负责人',
'activity.actions.ownerAccepted': '接手了任务',
'activity.actions.ownerRejected': '拒绝了接手',
'activity.actions.waitingOwner': '跳过 agent 运行:等待新负责人接手',
'inbox.types.owner_handoff': '等待接手',
'inbox.types.owner_handoff_rejected': '接手已拒绝',
'inbox.text.owner_handoff': '接手 {{identifier}}',
'inbox.text.owner_handoff_rejected': '{{identifier}} 的接手已拒绝',
'inbox.body.owner_handoff':
'请选择接手或拒绝,新负责人接手前 agent 运行保持暂停。',
'inbox.body.owner_handoff_rejected':
'新负责人拒绝了接手,负责人保持不变,agent 运行保持暂停。',
'issueRuns.model': '模型:{{model}}',
'issueRuns.configuredModel': '配置模型:{{model}}',
'issueRuns.modelUnknown': '模型尚未报告',
@@ -998,6 +1020,7 @@ const zhCN: AppResource = {
comment: '评论',
statusChange: '状态变更',
ownerChanged: '负责人变更',
ownerAccepted: '负责人接手',
projectChanged: '转项目',
unblocked: '阻塞解除',
subtasksFinished: '子任务完成',
@@ -1278,6 +1301,7 @@ const zhCN: AppResource = {
issueStart: { title: '现在开始?', later: '暂不开始', start: '开始' },
issueProgress: {
waiting: {
waitingOwner: '等待 {{owner}} 接手',
noOwner: '正在等待有效的任务负责人',
notConfigured: '{{agent}} 正在等待:尚未配置首选或备选模型',
noRunner: '{{agent}} 正在等待:没有能运行首选或备选模型的在线执行机',
@@ -1,8 +1,8 @@
/**
* `/chat/:conversationId`: one conversation as a page of its own, inside the application's shell. A conversation started
* from the home page lands here, and so do the home page's recent conversations and the chat panel's "Open full
* from the chat composer lands here, and so do the chat composer's recent conversations and the chat panel's "Open full
* screen". The agent-chat block renders it (`ChatConversationPage`, the panel's conversation view in its page form); a
* new conversation goes back to the home page, whose composer picks the agent.
* new conversation goes back to the chat composer to pick the agent.
*/
import type { ReactElement } from 'react';
import { Navigate, useParams } from 'react-router';
@@ -11,11 +11,11 @@ import { ChatConversationPage } from '@/extensions/nocobase-agent-chat/conversat
export default function ChatPage(): ReactElement {
const { conversationId } = useParams();
if (!conversationId) return <Navigate to='/' replace />;
if (!conversationId) return <Navigate to='/chat' replace />;
return (
<ChatConversationPage
conversationId={conversationId}
newConversationPath='/'
newConversationPath='/chat'
/>
);
}
@@ -1,5 +1,5 @@
/**
* The landing page, with the restraint of the AI products people already know (ChatGPT, Claude.ai, the Codex app): a
* The chat composer at `/chat`, with the restraint of the AI products people already know (ChatGPT, Claude.ai, the Codex app): a
* greeting, one composer (the UI Library's `agent-composer`), and the person's recent conversations under it, nothing
* else. Inside the composer's toolbar, beside the attach button, the person picks the agent among the enabled ones
* they may chat with, their default first (`model.ts`): the chat's `NewChatChoice`, the same picker a new conversation
@@ -44,7 +44,7 @@ import { cn } from 'cn';
const MODE_ICONS = { online: MessageSquareIcon, runner: CodeXmlIcon } as const;
export default function HomePage(): ReactElement | null {
export default function NewConversationPage(): ReactElement | null {
const { t } = useTranslation();
const api = useStudioApi();
const me = useQuery({ queryKey: studioKeys.me, queryFn: () => api.me() });
@@ -0,0 +1,8 @@
import type { ReactElement } from 'react';
import { Navigate, useLocation } from 'react-router';
/** Retain bookmarked composer links while removing the homepage. */
export default function HomeRedirect(): ReactElement {
const { search, hash } = useLocation();
return <Navigate to={{ pathname: '/chat', search, hash }} replace />;
}
@@ -0,0 +1,8 @@
import type { ReactElement } from 'react';
import { Navigate, useLocation } from 'react-router';
/** Preserve URL-addressed account settings and inbox filters when entering at the application root. */
export default function LandingPage(): ReactElement {
const { search, hash } = useLocation();
return <Navigate to={{ pathname: '/inbox', search, hash }} replace />;
}
+30 -18
View File
@@ -21,7 +21,7 @@ import {
CircleUserRound,
Cpu,
FolderKanban,
House,
InboxIcon,
LayoutDashboard,
ListChecks,
ListTodo,
@@ -86,10 +86,9 @@ function issueDetailRoute(owner?: string): AppClientRouteDefinition {
}
/**
* The sidebar: Home (0, the composer that starts a conversation) and Dashboard (1), then My
* follow-ups (1.5) and My issues (2) on top, then the sections Development (4: issues, projects, knowledge), Releases (4.5: Apps, environments)
* and Agent team (5: agents, runtimes, skills, models, usage), and Settings (7). The inbox is the header's button
* (`inbox/header-button.tsx`), not a sidebar entry. A section is a route without a page of its own;
* The sidebar: My tasks (0), Inbox (1), Dashboard (2), My follow-ups (3), then
* Development (4: issues, projects, knowledge), Releases (4.5: Apps, environments), Agent team (5) and Settings (7).
* The layout adds the inbox's live count to its route navigation. A section is a route without a page of its own;
* the layout draws it as a label over its entries (`layouts/components/navigation-menu.tsx`). The projects and
* agents plugins' pages are mounted here (`projects({ routes: false })`), so their navigation and breadcrumb titles are
* this application's. `/issues` and `/my-issues` are this application's own pages (`pages/issues`, `pages/my-issues`),
@@ -133,15 +132,28 @@ const releasesPage =
const appRoutes: AppClientRouteContribution = defineAppRoutes([
{
// Every signed-in user reaches the landing page. `authz: 'skip'` takes it out of page authorization entirely, so
// no permission change can leave a user signed in with nowhere to land. It is the composer that starts a
// conversation with an agent, in Online or Runner mode (`pages/home`), first in the menu.
// Every signed-in user has a landing page, independent of page grants.
authz: 'skip',
auth: 'required',
componentLoader: () => import('./pages/landing/index.js'),
name: 'landing',
path: '/',
},
{
// The composer starts a conversation with an agent, in Online or Runner mode.
authz: 'skip',
auth: 'required',
componentLoader: () => import('./pages/home/index.js'),
name: 'chat-new',
path: '/chat',
},
{
// Bookmarked home URLs now open the chat composer without a homepage menu entry.
authz: 'skip',
auth: 'required',
name: 'home',
path: '/',
navigation: { title: 'navigation.home', icon: House, order: 0 },
path: '/home',
componentLoader: () => import('./pages/home/redirect.js'),
},
{
// How delivery goes, how the agents perform and what is stuck (`pages/dashboard`), behind the `reports` grant,
@@ -153,15 +165,15 @@ const appRoutes: AppClientRouteContribution = defineAppRoutes([
navigation: {
title: 'navigation.dashboard',
icon: LayoutDashboard,
order: 1,
order: 2,
},
breadcrumb: { title: 'navigation.dashboard' },
componentLoader: () => import('./pages/dashboard/index.js'),
},
{
// One conversation, full screen (`pages/chat`): where the home page's composer and recent conversations lead, and
// One conversation, full screen (`pages/chat`): where the chat composer and recent conversations lead, and
// the chat panel's "Open full screen". A conversation is its owner's alone, which its API checks, so it needs no
// page grant. No menu entry: conversations are reached from the home page, the panel and its history.
// page grant. No menu entry: conversations are reached from the chat composer, the panel and its history.
authz: 'skip',
auth: 'required',
componentLoader: () => import('./pages/chat/index.js'),
@@ -186,18 +198,18 @@ const appRoutes: AppClientRouteContribution = defineAppRoutes([
],
},
{
// Everyone's own inbox: what it lists is the viewer's, so it needs no page grant. Opened from the header's inbox
// button, which carries the pending-decision count, so it has no sidebar entry.
// Everyone's own inbox is the default landing page; its contents need no page grant.
authz: 'skip',
auth: 'required',
componentLoader: () => import('./pages/inbox/index.js'),
name: 'inbox',
navigation: { title: 'navigation.inbox', icon: InboxIcon, order: 1 },
breadcrumb: { title: 'navigation.inbox' },
path: '/inbox',
},
{
// The issues the viewer follows and owns, each with what it waits for now and its live activity
// (`pages/follow-ups`), above My issues. It shows issues as My issues does, behind the same page grant.
// (`pages/follow-ups`), after Dashboard. It shows issues as My issues does, behind the same page grant.
name: 'follow-ups',
path: '/follow-ups',
auth: 'required',
@@ -205,7 +217,7 @@ const appRoutes: AppClientRouteContribution = defineAppRoutes([
navigation: {
title: 'navigation.followUps',
icon: ListChecks,
order: 1.5,
order: 3,
},
breadcrumb: { title: 'navigation.followUps' },
componentLoader: () => import('./pages/follow-ups/index.js'),
@@ -219,7 +231,7 @@ const appRoutes: AppClientRouteContribution = defineAppRoutes([
navigation: {
title: 'navigation.myIssues',
icon: CircleUserRound,
order: 2,
order: 0,
},
breadcrumb: { title: 'navigation.myIssues' },
componentLoader: () => import('./pages/my-issues/index.js'),
+2
View File
@@ -29,6 +29,8 @@
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-inbox-pending: var(--inbox-pending);
--color-inbox-pending-foreground: var(--inbox-pending-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
@@ -57,6 +57,8 @@
--popover-foreground: oklch(0.21 0.03 264.67);
--primary: oklch(0.48 0.2 260.48);
--primary-foreground: oklch(1 0 0);
--inbox-pending: oklch(0.769 0.188 70.08);
--inbox-pending-foreground: oklch(0.21 0.03 264.67);
--secondary: oklch(0.97 0 264.7);
--secondary-foreground: oklch(0.37 0.03 259.73);
--muted: oklch(0.97 0 264.7);
@@ -92,6 +94,8 @@
--popover-foreground: oklch(0.93 0.01 264.6);
--primary: oklch(0.56 0.24 260.95);
--primary-foreground: oklch(1 0 0);
--inbox-pending: oklch(0.769 0.188 70.08);
--inbox-pending-foreground: oklch(0.21 0.03 264.67);
--secondary: oklch(0.35 0.04 262.16);
--secondary-foreground: oklch(0.93 0.01 264.6);
--muted: oklch(0.3 0.03 261.75);
@@ -58,6 +58,8 @@
--popover-foreground: oklch(0.21 0.03 264.67);
--primary: oklch(0.48 0.2 260.48);
--primary-foreground: oklch(1 0 0);
--inbox-pending: oklch(0.769 0.188 70.08);
--inbox-pending-foreground: oklch(0.21 0.03 264.67);
--secondary: oklch(0.97 0 264.7);
--secondary-foreground: oklch(0.37 0.03 259.73);
--muted: oklch(0.97 0 264.7);
@@ -94,6 +96,8 @@
--popover-foreground: oklch(0.93 0.01 264.6);
--primary: oklch(0.56 0.24 260.95);
--primary-foreground: oklch(1 0 0);
--inbox-pending: oklch(0.769 0.188 70.08);
--inbox-pending-foreground: oklch(0.21 0.03 264.67);
--secondary: oklch(0.35 0.04 262.16);
--secondary-foreground: oklch(0.93 0.01 264.6);
--muted: oklch(0.3 0.03 261.75);
@@ -0,0 +1,22 @@
import { defineMigration, type MigrationDefinition } from '@nocobase/db';
const migration: MigrationDefinition = defineMigration({
name: '202610240040_studio_issue_owner_handoffs',
async up({ builder }) {
await builder.createCollection('studioIssueOwnerHandoffs', (collection) => {
collection.string('issueId', { length: 64 }).primary().notNull();
collection.boolean('waiting').notNull().defaultTo(false);
collection.string('ownerUserId', { length: 64 }).notNull();
collection.string('previousOwnerUserId', { length: 64 }).notNull();
collection.string('transferredByUserId', { length: 64 }).notNull();
collection.string('notificationKey', { length: 64 }).notNull();
collection.datetimeTz('transferredAt').notNull();
collection.datetimeTz('rejectedAt').nullable();
});
},
async down({ builder }) {
await builder.dropCollection('studioIssueOwnerHandoffs');
},
});
export default migration;
+3 -3
View File
@@ -43,15 +43,15 @@ Import `test` and `expect` from `e2e/support/fixtures.ts`. Its `page` is already
| `issues.test.ts` | The board as the default beside the list and the Agent queue, the board as my issues' default too, an agent's lane in the queue (waiting, working, queued) from a fake runner, search, creating an issue by hand and from notes in the AI tab, opening an issue by its identifier |
| `issue-page.test.ts` | Comments, attachments (upload and download), changing the status, a backlog issue started with Start, the project manager as the only agent offered to execute an issue, an agent's run and reply appearing without a reload |
| `inbox.test.ts` | A mention reaching the notifications and opening its issue |
| `header.test.ts` | The header's inbox button: its count following a new decision live, the active state on the inbox, keyboard and phone width; no settings entry, `/settings` URLs landing on the home page, Studio's settings in the sidebar |
| `header.test.ts` | The sidebar inbox entry: live unread counts, decision priority, active state, keyboard and mobile menu; root landing and direct URLs; no header inbox or settings entry, `/settings` URLs landing on the inbox, Studios settings in the sidebar |
| `knowledge.test.ts` | System knowledge, editing a project document and comparing its versions, accepting an agent's proposed change |
| `settings.test.ts` | The settings navigation, members and roles, labels, an organization API key (secret shown once, used, rotated) |
| `run-transcript.test.ts` | A real 200-event run: default type filters, individual hidden segments, persisted choices, live errors, account isolation, narrow screens and dark mode |
| `account.test.ts` | The colour mode persisting across reloads and browsers, the profile |
| `releases.test.ts` | The environments list, creating a protected in-process App Host environment without deploying, the Apps page |
| `models.test.ts` | Agent team › Models: adding an OpenAI-compatible service against the mock model in one sheet (provider type, name, base URL, write-only key, connection test, models fetched and one checked), the key shown as set when reopened, the (service, model) pair priced on the Usage page's Prices tab, and a coding tool billed by subscription |
| `home.test.ts` | The home composer: the team's default to start with and agents no runner can take disabled with the reason, picking a coding agent (the message handed to it) and landing on the full-page conversation; the recent conversations open there, and it moves to the side panel |
| `mobile.test.ts` | No horizontal overflow at 390 px on the key pages, the navigation on a phone, the floating chat button clear of the comment composer and of every pinned control |
| `home.test.ts` | The chat composer at `/chat`: the team default and unavailable runners, picking a coding agent, opening recent conversations, in-place panel expansion, and starting a new conversation at `/chat` |
| `mobile.test.ts` | No horizontal overflow at 390 px on the key pages, navigation on a phone, the multiline header chat editor and no duplicate floating launcher |
## Screenshots
+2 -2
View File
@@ -1,4 +1,4 @@
import { atHome, expect, open, test } from './support/fixtures.ts';
import { atInbox, expect, open, test } from './support/fixtures.ts';
// Preferences follow the person, so these tests use an account no other test signs in as.
test.use({ user: 'wendy' });
@@ -64,7 +64,7 @@ test.describe('personal settings', () => {
test('the profile page shows the signed-in person', async ({ page }) => {
await open(page, '/account');
await expect(page).toHaveURL(atHome('?account=profile'));
await expect(page).toHaveURL(atInbox('?account=profile'));
await expect(page.getByRole('dialog', { name: '个人设置' })).toBeVisible();
const profile = page.getByRole('region', { name: '个人资料' });
await expect(
+6 -5
View File
@@ -19,12 +19,13 @@ test.describe('my follow-ups', () => {
await api.put('followUps/following', { issueIds: [] });
await open(page, '/follow-ups');
// The menu entry sits right above My issues.
// Follow-ups follows the default landing navigation, including the inbox's live count.
const menu = page.getByRole('navigation', { name: '应用导航' });
const entries = await menu.getByRole('link').allInnerTexts();
const position = entries.findIndex((text) => text.includes('我的跟进'));
expect(position).toBeGreaterThanOrEqual(0);
expect(entries[position + 1]).toContain('我的任务');
const entries = menu.getByRole('link');
await expect(entries.nth(0)).toHaveAccessibleName('我的任务');
await expect(entries.nth(1)).toHaveAccessibleName(/^收件箱(?:,.*)?$/u);
await expect(entries.nth(2)).toHaveAccessibleName('仪表盘');
await expect(entries.nth(3)).toHaveAccessibleName('我的跟进');
await expect(
page.getByRole('heading', { name: '我的跟进', level: 1 }),
).toBeVisible();
+111 -9
View File
@@ -12,9 +12,11 @@ import {
} from './support/fixtures.ts';
import type { Issue } from './support/runner.ts';
/** The header's inbox button, whatever its label says about the count; not the breadcrumb on the inbox itself. */
/** The sidebar's inbox entry, including its full count label. */
const inboxButton = (page: Page) =>
page.getByRole('banner').getByTestId('studio-inbox-button');
page
.getByRole('navigation', { name: '应用导航' })
.getByRole('link', { name: /^收件箱/ });
/** The decisions waiting on the viewer, as the server counts them. */
async function pendingDecisions(api: Api): Promise<number> {
@@ -28,7 +30,7 @@ async function unreadCount(api: Api): Promise<number> {
).count;
}
test.describe('header inbox', () => {
test.describe('sidebar inbox', () => {
// Lisa Nguyen, a contributor, is mentioned by Wendy Foster. No other test mentions her or asks her for a decision,
// so the count is hers alone.
test.use({ user: 'lisa' });
@@ -47,7 +49,7 @@ test.describe('header inbox', () => {
try {
const me = await wendy.api.get<{ userId: string }>('projects/me');
const issue = await wendy.api.post<Issue>('projects/issues', {
title: `顶栏收件箱角标 ${unique()}`,
title: `侧栏收件箱角标 ${unique()}`,
ownerUserId: me.userId,
});
await wendy.api.post(`projects/issues/${issue.id}/comments`, {
@@ -122,11 +124,17 @@ test.describe('header inbox', () => {
page.getByRole('heading', { name: '收件箱', level: 1 }),
).toBeVisible();
await expect(inboxButton(page)).toHaveAttribute('aria-current', 'page');
// The sidebar has no inbox entry any more.
// The sidebar is the only global inbox entry.
await expect(
page
.getByRole('navigation', { name: '应用导航' })
.getByRole('link', { name: /收件箱/ }),
).toHaveCount(1);
await expect(
page
.getByRole('banner')
.getByRole('link', { name: /收件箱/ })
.and(page.locator('a[href]')),
).toHaveCount(0);
});
@@ -146,14 +154,29 @@ test.describe('header inbox', () => {
hasTouch: true,
});
test('the button stays in the header without pushing it sideways', async ({
test('the inbox is reachable in the mobile menu without horizontal overflow', async ({
page,
}) => {
await open(page, '/');
await open(page, '/my-issues');
await page
.getByRole('banner')
.getByRole('button', { name: /导航/ })
.click();
const nav = page.getByRole('navigation', { name: '应用导航' });
const links = nav.getByRole('link');
await expect(links.nth(0)).toHaveAccessibleName('我的任务');
await expect(links.nth(1)).toHaveAccessibleName(/^收件箱/);
await expect(links.nth(2)).toHaveAccessibleName('仪表盘');
await expect(
nav.getByRole('link', { name: '首页', exact: true }),
).toHaveCount(0);
await expect(inboxButton(page)).toBeVisible();
expect(await horizontalOverflow(page)).toBeLessThanOrEqual(0);
await inboxButton(page).click();
await expect(page).toHaveURL(/\/inbox$/u);
await expect(
page.getByRole('navigation', { name: '应用导航' }),
).toBeHidden();
});
});
});
@@ -175,9 +198,9 @@ test.describe('back-office settings', () => {
// The catch-all route sends it to the application's root, with or without the trailing slash.
await expect
.poll(() => page.url().replace(/\/$/u, ''))
.toBe(url('/').replace(/\/$/u, ''));
.toBe(url('/inbox'));
await expect(
page.getByRole('heading', { name: '今天要做什么?' }),
page.getByRole('heading', { name: '收件箱', level: 1 }),
).toBeVisible();
});
}
@@ -194,3 +217,82 @@ test.describe('back-office settings', () => {
).toBeVisible();
});
});
test.describe('default landing navigation', () => {
test('root lands on inbox with the menu in order, and direct destinations survive refresh and history', async ({
page,
}) => {
await open(page, '/');
await expect(page).toHaveURL(url('/inbox'));
const nav = page.getByRole('navigation', { name: '应用导航' });
const links = nav.getByRole('link');
await expect(links.nth(0)).toHaveAccessibleName('我的任务');
await expect(links.nth(1)).toHaveAccessibleName(/^收件箱/);
await expect(links.nth(2)).toHaveAccessibleName('仪表盘');
await expect(
nav.getByRole('link', { name: '首页', exact: true }),
).toHaveCount(0);
await nav.getByRole('link', { name: '仪表盘' }).click();
await expect(page).toHaveURL(url('/dashboard'));
await page.reload();
await expect(
page.getByRole('heading', { name: '仪表盘', level: 1 }),
).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(url('/inbox'));
await page.goForward();
await expect(page).toHaveURL(url('/dashboard'));
await open(page, '/inbox?kind=decision');
await page.reload();
await expect(page).toHaveURL(url('/inbox?kind=decision'));
await expect(
page.getByRole('heading', { name: '收件箱', level: 1 }),
).toBeVisible();
});
test('legacy home redirects to the chat composer with query and fragment preserved', async ({
page,
}) => {
await open(page, '/home?account=preferences#detail');
await expect(page).toHaveURL(url('/chat?account=preferences#detail'));
await expect(page.getByRole('dialog', { name: '个人设置' })).toBeVisible();
await page.reload();
await expect(page).toHaveURL(url('/chat?account=preferences#detail'));
});
test('root preserves account settings and fragment', async ({ page }) => {
await open(page, '/?account=preferences#detail');
await expect(page).toHaveURL(url('/inbox?account=preferences#detail'));
await expect(page.getByRole('dialog', { name: '个人设置' })).toBeVisible();
});
test('icon mode retains the full count and tooltip', async ({ page }) => {
await page.route('**/api/inbox/pending', (route) =>
route.fulfill({ json: { data: { decision: 120 } } }),
);
await open(page, '/inbox');
await page
.getByRole('banner')
.getByRole('button', { name: /导航/ })
.click();
const nav = page.getByRole('navigation', { name: '应用导航' });
const links = nav.getByRole('link');
await expect(links.nth(0)).toHaveAccessibleName('我的任务');
await expect(links.nth(1)).toHaveAccessibleName(/^收件箱/);
await expect(links.nth(2)).toHaveAccessibleName('仪表盘');
await expect(
nav.getByRole('link', { name: '首页', exact: true }),
).toHaveCount(0);
const link = inboxButton(page);
await expect(link).toHaveAccessibleName('收件箱,120 项待处理');
await expect(link.getByTestId('studio-inbox-badge')).toBeVisible();
await expect(link.getByTestId('studio-inbox-badge')).toHaveText('99+');
await link.hover();
await expect(
page.getByText('120 项待处理决定,不是未读数;处理后才会减少。', {
exact: true,
}),
).toBeVisible();
expect(await horizontalOverflow(page)).toBeLessThanOrEqual(0);
});
});
+26 -15
View File
@@ -1,18 +1,11 @@
/**
* The home page's composer: pick an agent in its toolbar, send, and land on the conversation full screen (`/chat/:id`).
* The chat composer's composer: pick an agent in its toolbar, send, and land on the conversation full screen (`/chat/:id`).
* The picker lists the enabled agents, the team's default (the project manager's first choice) to start with, and an
* agent no runner can take now is disabled with the reason. A coding agent on a runner answers, and none runs here, so
* the tests check what was sent rather than an answer. The recent conversations under the composer open full screen
* too, and a conversation moves from there to the side panel.
*/
import {
apiAs,
expect,
open,
server,
test,
unique,
} from './support/fixtures.ts';
import { apiAs, expect, open, url, test, unique } from './support/fixtures.ts';
import { FakeRunner } from './support/runner.ts';
interface Conversation {
@@ -22,14 +15,32 @@ interface Conversation {
readonly agent: { readonly name: string } | null;
}
// This file reads the person's recent conversations, so it uses an account no other test opens the home page as.
// This file reads the person's recent conversations, so it uses an account no other test opens the chat composer as.
test.use({ user: 'leo' });
test.describe('home composer', () => {
test.describe('chat composer', () => {
test('starts a new conversation from the conversation page at /chat', async ({
page,
api,
}) => {
const conversation = await api.post<{ id: string }>(
'agents/conversations',
{},
);
await open(page, `/chat/${conversation.id}`);
await page
.getByTestId('chat-page')
.getByRole('button', { name: '新对话', exact: true })
.click();
await expect(page).toHaveURL(url('/chat'));
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('chat-floating-button')).toHaveCount(0);
});
test('starts with the team default and disables agents no runner can take', async ({
page,
}) => {
await open(page, '/');
await open(page, '/chat');
const main = page.getByRole('main');
await expect(
main.getByRole('heading', { name: '今天要做什么?', level: 1 }),
@@ -68,7 +79,7 @@ test.describe('home composer', () => {
} finally {
await admin.close();
}
await open(page, '/');
await open(page, '/chat');
const main = page.getByRole('main');
const composer = main.getByTestId('chat-composer');
await composer.getByTestId('chat-agent-picker').click();
@@ -123,7 +134,7 @@ test.describe('home composer', () => {
title,
});
await open(page, '/');
await open(page, '/chat');
const recent = page.getByRole('list', { name: '最近的对话' });
await recent.getByRole('button', { name: new RegExp(title, 'u') }).click();
await expect(page).toHaveURL(new RegExp(`/chat/${conversation.id}$`, 'u'));
@@ -134,7 +145,7 @@ test.describe('home composer', () => {
await view.getByRole('button', { name: '在侧栏中打开' }).click();
await expect
.poll(() => new URL(page.url()).pathname.replace(/\/$/u, ''))
.toBe(new URL(server.baseURL).pathname);
.toBe(new URL(url('/chat')).pathname);
const panel = page.getByRole('complementary', { name: 'Agent 对话' });
await expect(panel).toBeVisible();
await expect(panel.getByTestId('chat-title')).toHaveText(title);
+2 -1
View File
@@ -8,7 +8,8 @@ test.use({
/** Key pages at phone width, and what shows that each one rendered. */
const PAGES: readonly { path: string; heading: string | null }[] = [
{ path: '/', heading: '今天要做什么?' },
{ path: '/', heading: '收件箱' },
{ path: '/chat', heading: '今天要做什么?' },
{ path: '/dashboard', heading: '仪表盘' },
{ path: '/inbox', heading: '收件箱' },
{ path: '/follow-ups', heading: '我的跟进' },
+144
View File
@@ -0,0 +1,144 @@
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import { expect, horizontalOverflow, open, test } from './support/fixtures.ts';
import { STUDIO_ROOT } from './support/server.ts';
// The initial demo administrator is isolated from the accounts used by the workflow tests.
test.use({ user: 'admin' });
test('inbox navigation adapts to languages, themes, icon mode and the mobile drawer', async ({
page,
api,
}) => {
// Capture all 24 combinations without changing per-interaction assertion timeouts.
test.setTimeout(120_000);
const directory = path.join(
STUDIO_ROOT,
'storage/ui-workflow/inbox-navigation/screenshots',
);
mkdirSync(directory, { recursive: true });
await page.route('**/api/inbox/pending', (route) =>
route.fulfill({ json: { data: { decision: 120 } } }),
);
for (const preset of ['compact', 'default']) {
for (const locale of ['en-US', 'zh-CN']) {
for (const mode of ['light', 'dark']) {
await api.patch('users/me/preferences', {
locale,
'theme.mode': mode,
'theme.preset': preset,
});
await page.setViewportSize({ width: 1440, height: 900 });
await open(page, '/inbox');
const name =
locale === 'en-US' ? 'Inbox, 120 waiting' : '收件箱,120 项待处理';
const nav = page.getByRole('navigation', {
name: locale === 'en-US' ? 'Application navigation' : '应用导航',
});
const links = nav.getByRole('link');
await expect(links.nth(0)).toHaveAccessibleName(
locale === 'en-US' ? 'My issues' : '我的任务',
);
await expect(links.nth(1)).toHaveAccessibleName(name);
await expect(links.nth(2)).toHaveAccessibleName(
locale === 'en-US' ? 'Dashboard' : '仪表盘',
);
await expect(
nav.getByRole('link', {
name: locale === 'en-US' ? 'Home' : '首页',
exact: true,
}),
).toHaveCount(0);
const toggle = page.getByRole('banner').getByRole('button', {
name: locale === 'en-US' ? /navigation/i : /导航/,
});
const sidebar = page.locator('[data-slot=sidebar]').first();
if ((await sidebar.getAttribute('data-state')) === 'collapsed')
await toggle.click();
const link = nav.getByRole('link', { name, exact: true });
await expect(link).toBeVisible();
await expect(link.getByTestId('studio-inbox-badge')).toHaveText('99+');
await expect(
page
.getByRole('banner')
.getByRole('link', { name: /Inbox|收件箱/ })
.and(page.locator('a[href]')),
).toHaveCount(0);
await expect(page.locator('html')).toHaveAttribute(
'data-theme',
preset,
);
await expect(page.locator('html')).toHaveClass(new RegExp(mode, 'u'));
// Canvas resolves the semantic OKLCH colors into sRGB for the AA calculation.
const contrast = await page.evaluate<number>(`(() => {
const badge = document.querySelector('[data-testid="studio-inbox-badge"]');
const style = getComputedStyle(badge);
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const context = canvas.getContext('2d');
const luminance = (color) => {
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
const rgb = Array.from(context.getImageData(0, 0, 1, 1).data).slice(0, 3);
const linear = rgb.map(value => {
const channel = value / 255;
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
});
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
};
const background = luminance(style.backgroundColor);
const foreground = luminance(style.color);
return (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05);
})()`);
expect(contrast).toBeGreaterThanOrEqual(4.5);
await page.screenshot({
path: path.join(
directory,
`${preset}-${locale}-${mode}-expanded.png`,
),
animations: 'disabled',
});
await toggle.click();
await expect(sidebar).toHaveAttribute('data-state', 'collapsed');
await expect(link.getByTestId('studio-inbox-badge')).toBeVisible();
await expect
.poll(() =>
page.evaluate<boolean>(`(() => {
const element = document.querySelector('[data-testid="studio-inbox-badge"]');
const badge = element.getBoundingClientRect();
const sidebar = element.closest('[data-slot="sidebar-content"]').getBoundingClientRect();
return badge.left >= sidebar.left && badge.right <= sidebar.right && badge.width >= 18;
})()`),
)
.toBe(true);
await link.focus();
await expect(link).toBeFocused();
await page.keyboard.press('Enter');
await page.screenshot({
path: path.join(
directory,
`${preset}-${locale}-${mode}-collapsed.png`,
),
animations: 'disabled',
});
await page.setViewportSize({ width: 375, height: 812 });
await expect(nav).toBeHidden();
await toggle.click();
await expect(nav).toBeVisible();
await expect(
nav
.getByRole('link', { name, exact: true })
.getByTestId('studio-inbox-badge'),
).toBeVisible();
expect(await horizontalOverflow(page)).toBeLessThanOrEqual(0);
await page.screenshot({
path: path.join(directory, `${preset}-${locale}-${mode}-mobile.png`),
animations: 'disabled',
});
await nav.getByRole('link', { name, exact: true }).click();
await expect(nav).toBeHidden();
}
}
}
});
@@ -0,0 +1,110 @@
import {
apiAs,
expect,
open,
signIn,
test,
unique,
horizontalOverflow,
} from './support/fixtures.ts';
import { FakeRunner, type Issue } from './support/runner.ts';
test('owner handoff controls in the issue and inbox, in Chinese and English', async ({
page,
api,
browser,
context,
}) => {
test.setTimeout(120_000);
const admin = await apiAs(browser, 'admin');
const lisa = await apiAs(browser, 'lisa');
const me = await api.get<{ userId: string }>('projects/me');
const next = await lisa.api.get<{ userId: string }>('projects/me');
const settings = await admin.api.get<{
firstChoiceAgentId: string | null;
fallbackAgentId: string | null;
}>('projectManager');
try {
const runner = new FakeRunner(admin.api, `handoff-${unique()}`);
const agent = await runner.agent();
await admin.api.patch('projectManager', {
firstChoiceAgentId: agent,
fallbackAgentId: null,
});
const issue = await api.post<Issue>('projects/issues', {
title: `负责人接手 ${unique()}`,
ownerUserId: me.userId,
statusKey: 'in_progress',
start: false,
});
await api.post(`projectManager/issues/${issue.id}/start`);
const before = await api.get<Issue>(`projects/issues/${issue.id}`);
await api.patch(`projects/issues/${issue.id}`, {
revision: before.revision,
ownerUserId: next.userId,
});
await open(page, `/issues/${issue.identifier}`);
await expect(
page.getByText('等待 Lisa Nguyen 接手', { exact: true }),
).toBeVisible();
await expect(
page.getByRole('button', { name: '接手', exact: true }),
).toHaveCount(0);
await signIn(context, 'lisa');
await open(page, `/issues/${issue.identifier}`);
await expect(
page.getByRole('button', { name: '接手', exact: true }),
).toBeVisible();
await page.screenshot({
path: test.info().outputPath('owner-handoff-zh.png'),
});
await open(page, '/inbox');
await page
.getByRole('button', { name: new RegExp(issue.identifier) })
.first()
.click();
const card = page
.getByRole('article')
.filter({ hasText: issue.title })
.first();
await expect(
card.getByRole('button', { name: '接手', exact: true }),
).toBeVisible();
await expect(
card.getByRole('button', { name: '拒绝', exact: true }),
).toBeVisible();
await page.screenshot({
path: test.info().outputPath('owner-handoff-inbox-zh.png'),
});
await lisa.api.patch('users/me/preferences', { locale: 'en-US' });
await page.evaluate(() => localStorage.setItem('nocobase.locale', 'en-US'));
await page.setViewportSize({ width: 375, height: 812 });
await open(page, `/issues/${issue.identifier}`);
await expect(
page.getByText('Waiting for Lisa Nguyen to take over', { exact: true }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Take over', exact: true }),
).toBeVisible();
expect(await horizontalOverflow(page)).toBeLessThanOrEqual(1);
await page.screenshot({
path: test.info().outputPath('owner-handoff-en-mobile.png'),
});
await page.getByRole('button', { name: 'Take over', exact: true }).click();
await expect(
page.getByText('Waiting for Lisa Nguyen to take over', { exact: true }),
).toHaveCount(0);
const current = await lisa.api.get<{ handoff: { waiting: boolean } }>(
`issueOwnerHandoffs/${issue.id}`,
);
expect(current.handoff.waiting).toBe(false);
} finally {
await lisa.api.patch('users/me/preferences', { locale: 'zh-CN' });
await admin.api.patch('projectManager', {
firstChoiceAgentId: settings.firstChoiceAgentId,
fallbackAgentId: settings.fallbackAgentId,
});
await lisa.close();
await admin.close();
}
});
+3 -3
View File
@@ -1,7 +1,7 @@
import type { APIRequestContext, Page } from '@playwright/test';
import {
atHome,
atInbox,
expect,
open,
server,
@@ -45,12 +45,12 @@ test.describe('settings', () => {
section.getByRole('heading', { name, level: 1 }),
).toBeVisible();
}
// Opened directly, the settings' Back leads home.
// Opened directly, the settings' Back leads to the default inbox.
await page
.getByRole('navigation', { name: '设置导航' })
.getByRole('link', { name: '返回' })
.click();
await expect(page).toHaveURL(atHome());
await expect(page).toHaveURL(atInbox());
});
test('a member is given the admin role and has it taken away again', async ({
+13
View File
@@ -3,6 +3,19 @@ import { expect, test } from '@playwright/test';
import { USERS, url } from './support/fixtures.ts';
test.describe('sign-in', () => {
test('signing in from root lands on inbox', async ({ page }) => {
await page.goto(url('/'));
await expect(page).toHaveURL(/\/login/);
await page
.getByRole('textbox', { name: '用户名或邮箱' })
.fill(USERS.lisa.email);
await page.getByRole('textbox', { name: '密码' }).fill(USERS.lisa.password);
await page.getByRole('button', { name: '登录' }).click();
await expect(page).toHaveURL(url('/inbox'));
await expect(
page.getByRole('heading', { name: '收件箱', level: 1 }),
).toBeVisible();
});
test('a signed-out visitor is sent to the sign-in page and signs in as a demo user', async ({
page,
}) => {
+3 -3
View File
@@ -54,11 +54,11 @@ export const server = readState();
export const url = (path: string): string =>
`${server.baseURL}${path.startsWith('/') ? path : `/${path}`}`;
/** Matches Studio's home page, with or without a trailing slash after the mount, and the given query. */
export const atHome =
/** Matches Studio's default inbox page and the given query. */
export const atInbox =
(search = '') =>
(address: URL): boolean =>
address.pathname.replace(/\/$/u, '') === new URL(server.baseURL).pathname &&
address.pathname === new URL(url('/inbox')).pathname &&
address.search === search;
/**
+2 -2
View File
@@ -232,8 +232,8 @@
"check": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build",
"fix": "pnpm lint:fix && pnpm format",
"typecheck": "tsc --noEmit && tsc -p tsconfig.node.json --noEmit && tsc -p tsconfig.server.json --noEmit",
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix --max-warnings 0",
"lint": "node --max-old-space-size=4096 ./node_modules/eslint/bin/eslint.js . --max-warnings 0",
"lint:fix": "node --max-old-space-size=4096 ./node_modules/eslint/bin/eslint.js . --fix --max-warnings 0",
"format": "prettier . --write",
"format:check": "prettier . --check",
"test": "vitest run",
+16 -1
View File
@@ -96,6 +96,12 @@ import { createAgentWork } from './work.js';
import { commentRunNoticeRule } from './comment-run-limit.js';
import { reportCommentRuns } from './run-report.js';
import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js';
import {
OWNER_HANDOFF_NOTICE,
ownerHandoffNoticeRule,
settleOwnerHandoffNotice,
type HandoffAnnouncement,
} from '../issues/owner-handoff-notices.js';
export interface StudioAgentsDeps {
readonly agents: Agents;
@@ -155,11 +161,11 @@ export function bindStudioAgents(deps: StudioAgentsDeps): () => void {
const { agents } = deps;
const onError =
deps.onError ?? ((message, error) => console.error(message, error));
const work = createAgentWork({ agents, projects: deps.projects });
const askerOf = createAskerLookup(agents);
const permissions = createPermissionSource(deps.access, askerOf);
const viewerOf = (userId: string) =>
permissions.viewerOf({ kind: 'user', userId, displayName: userId });
const work = createAgentWork({ agents, projects: deps.projects, viewerOf });
const direct = createDirectWrites({ agents, projects: deps.projects });
const principal = createRunPrincipal({
askerOf,
@@ -175,6 +181,14 @@ export function bindStudioAgents(deps: StudioAgentsDeps): () => void {
inbox,
});
const releases: (() => void)[] = [
deps.projects().events.on('work.announced', async (event) => {
const port = deps.inboxPort?.();
if (port && event.kind === OWNER_HANDOFF_NOTICE)
await settleOwnerHandoffNotice(
port,
event.payload as HandoffAnnouncement,
);
}),
deps.kinds.add(createAgentKind({ agents, work })),
agents.subjects.register({
...issueBinding(
@@ -287,6 +301,7 @@ export function bindStudioAgents(deps: StudioAgentsDeps): () => void {
deps.noticeRules.add(runFailedRule(agents, deps.projects)),
deps.noticeRules.add(agentBlockedRule(deps.projects, agents)),
deps.noticeRules.add(commentRunNoticeRule()),
deps.noticeRules.add(ownerHandoffNoticeRule()),
);
// The project manager runs with the tool and model of the agents it names: one of them changing changes it.
const manager = deps.manager;
@@ -20,6 +20,7 @@ export const ISSUE_TRIGGERS = [
'comment',
'statusChange',
'ownerChanged',
'ownerAccepted',
// Moved to another project: the work starts again in its working directories (`work.ts`).
'projectChanged',
'unblocked',
@@ -21,6 +21,7 @@ import {
type HeldCommentWake,
} from './comment-guard.js';
import { createAgentWork } from './work.js';
import { ownerHandoffWaiting } from '../issues/owner-handoff.js';
/** The notice type of the owner's card. */
export const COMMENT_RUNS_LIMITED = 'comment_runs_limited';
@@ -91,6 +92,7 @@ export async function pendingCommentRun(
} | null> {
const issue = await projects.issueQueries.detail(viewer, issueId);
if (!mayContinue(viewer) || viewer.userId !== issue.ownerUserId) return null;
if (await ownerHandoffWaiting(projects.tx.read(), issue)) return null;
const guard = await commentGuards(projects.tx.read()).findOne({
filter: { id: commentGuardId(issue.id) },
});
@@ -126,6 +128,12 @@ export async function continueCommentRun(
'The current issue owner must continue this work.',
);
const id = commentGuardId(issue.id);
if (await ownerHandoffWaiting(tx.conn, issue))
throw studioError(
'FAILED_PRECONDITION',
'ISSUE_OWNER_HANDOFF_PENDING',
'Accept the owner handoff before continuing this work.',
);
const stale = () =>
studioError(
'FAILED_PRECONDITION',
@@ -7,12 +7,15 @@ import type { Projects } from '@nocobase/app-plugin-projects/server/tokens';
import type { DatabaseConnection } from '@nocobase/db';
import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js';
import { AGENT_KIND } from './tx.js';
import { readOwnerHandoff } from '../issues/owner-handoff.js';
export function issueExecutionIdentity(
agents: Pick<Agents, 'runs' | 'agents'>,
projects: () => Pick<Projects, 'issueContext' | 'commentQueries'>,
): NonNullable<SubjectBinding['actsAs']> {
return async (conn, work) => {
if ((await readOwnerHandoff(conn, work.subjectId))?.waiting)
return { actorUserId: null, reason: 'waitingOwner' };
const seen = new Set<string>();
const comments = await projects().commentQueries.list(conn, work.subjectId);
const byId = new Map(comments.map((comment) => [comment.id, comment]));
@@ -50,6 +50,7 @@ import {
} from './catalog/triggers.js';
import { AGENT_KIND } from './tx.js';
import { issueExecutionIdentity } from './execution-identity.js';
import { readOwnerHandoff } from '../issues/owner-handoff.js';
const SUBJECT_NOUN = 'issue';
@@ -425,6 +426,11 @@ export function createIssueContextProvider(
displayName: '',
}),
async admit(conn, claim) {
if ((await readOwnerHandoff(conn, claim.run.subject.id))?.waiting)
return {
reason: 'waitingOwner',
detail: 'Waiting for the new issue owner to take over.',
};
const context = await projects().issueContext.contextFor(
conn,
claim.run.subject.id,
@@ -25,6 +25,7 @@ import {
validIssueOwner,
} from '../execution-identity.js';
import { userName } from '../work.js';
import { ownerHandoffWaiting } from '../../issues/owner-handoff.js';
/** How much of a step or a question the line carries. */
const LINE_LENGTH = 160;
@@ -129,6 +130,18 @@ export async function issueProgress(
handOver: managed && open && managerRuns.length === 0,
};
if (await ownerHandoffWaiting(conn, issue))
return {
...base,
handOver: false,
waiting: {
reason: 'waitingOwner',
owner: {
id: issue.ownerUserId,
name: await userName(conn, issue.ownerUserId),
},
},
};
const runs = await deps.agents.runs.openOn(conn, subject);
const held = runs.filter((run) => HELD.has(run.status));
const queued = runs.filter((run) => run.status === 'queued');
@@ -42,6 +42,7 @@ export const IssueProgressSchema: z.ZodType<IssueProgress> = z
waiting: z
.object({
reason: z.enum([
'waitingOwner',
'noOwner',
'notConfigured',
'noRunner',
+195 -3
View File
@@ -50,6 +50,7 @@ import type {
IssueWorkHandler,
Projects,
ProjectsTx,
Viewer,
RunAttempt,
} from '@nocobase/app-plugin-projects/server/tokens';
import type { Priority } from '@nocobase/app-plugin-projects/shared/common';
@@ -86,6 +87,12 @@ import {
import { readManagerSettings } from './manager/settings.js';
import { COMMENT_RUN_WINDOW_HOURS } from '../../shared/project-manager.js';
import { AGENT_KIND, agentsTx } from './tx.js';
import {
ownerHandoffWaiting,
readOwnerHandoff,
recordOwnerHandoff,
} from '../issues/owner-handoff.js';
import { announceOwnerHandoff } from '../issues/owner-handoff-notices.js';
import { issueExecutionIdentity } from './execution-identity.js';
interface Wake {
@@ -144,6 +151,12 @@ const agentOf = (issue: Issue | null): string | null =>
/** The work handler, and "Start" on an issue its agent executes. */
export interface AgentWork extends IssueWorkHandler {
takeOver(
tx: ProjectsTx,
issue: Issue,
actor: Actor,
viewer?: Viewer,
): Promise<RunAttempt>;
/**
* Starts the agent executing `issue` for `actor` (trigger `started`): unless the issue is dormant, waits for
* unfinished issues, or the actor may not wake the agent (`skipped`).
@@ -170,8 +183,14 @@ export function createAgentWork(deps: {
readonly agents: Pick<Agents, 'runs' | 'agents'>;
readonly projects: () => Pick<
Projects,
'subtasks' | 'issueContext' | 'commentQueries' | 'activity'
| 'subtasks'
| 'issueContext'
| 'commentQueries'
| 'activity'
| 'issues'
| 'tx'
>;
readonly viewerOf?: (userId: string) => Promise<Viewer>;
}): AgentWork {
const { runs } = deps.agents;
const subjectOf = (issue: Issue) => ({ kind: ISSUE_SUBJECT, id: issue.id });
@@ -183,6 +202,8 @@ export function createAgentWork(deps: {
subjectId: request.issue.id,
triggerType: request.triggerType,
};
if (await ownerHandoffWaiting(tx.conn, request.issue))
return { ...attempt, started: false, skipped: 'waitingOwner' };
const agent = await deps.agents.agents.findWorkable(
tx.conn,
request.agentId,
@@ -566,9 +587,54 @@ export function createAgentWork(deps: {
});
}
async function takeOver(
tx: ProjectsTx,
issue: Issue,
actor: Actor,
viewer?: Viewer,
): Promise<RunAttempt> {
requireOwnerStart(issue, actor);
if (
agentOf(issue) !== PROJECT_MANAGER_ID &&
(await deps.agents.agents.findWorkable(tx.conn, PROJECT_MANAGER_ID))
) {
const claimant =
viewer ?? (actor.id ? await deps.viewerOf?.(actor.id) : undefined);
if (claimant)
issue = {
...issue,
...(await deps.projects().tx.savepoint(tx, () =>
deps.projects().issues.update(claimant, issue.id, {
revision: issue.revision,
executor: { type: AGENT_KIND, id: PROJECT_MANAGER_ID },
start: false,
}),
)),
};
}
if (dormant(issue))
return skipped(PROJECT_MANAGER_ID, issue, 'ownerChanged', 'dormant');
const who = await actorOf(tx, actor, issue);
return wake(tx, {
agentId: PROJECT_MANAGER_ID,
issue,
actorUserId: issue.ownerUserId,
triggerType: 'ownerAccepted',
input: {
type: 'signal',
actor: who.ref,
text: `${who.ref.name} accepted responsibility for ${issue.identifier}. Continue as the current owner and read all comments since your last run.`,
},
});
}
return {
observesComments: true,
observesOwnerClaims: true,
takeOver,
async onRunFinished(tx, run) {
if (run.subject.kind !== ISSUE_SUBJECT) return;
if ((await readOwnerHandoff(tx.conn, run.subject.id))?.waiting) return;
// An owner transfer cancels project-move continuation too; it must not restart work on its own.
const previous = await runs.identityInputs(tx.conn, run.id);
if (
@@ -667,6 +733,12 @@ export function createAgentWork(deps: {
async startNow(tx, issue, actor) {
requireOwnerStart(issue, actor);
if (await ownerHandoffWaiting(tx.conn, issue))
throw studioError(
'FAILED_PRECONDITION',
'ISSUE_OWNER_HANDOFF_PENDING',
'The issue is waiting for its new owner to take over.',
);
const agentId = agentOf(issue);
if (!agentId) throw new Error('No agent executes the issue.');
return assigned(
@@ -680,8 +752,43 @@ export function createAgentWork(deps: {
async onIssueChanged(tx, change) {
const { before, after } = change;
// The owner-change hook withdrew queued work and requested cancellation; the waiting-for-takeover flow controls when work resumes.
if (before && before.ownerUserId !== after.ownerUserId) return [];
if (change.ownerClaimed && !tx.rehearsal) {
const pending = await readOwnerHandoff(tx.conn, after.id);
if (pending?.waiting) {
const claimed = await recordOwnerHandoff(tx.conn, {
...pending,
authenticatedPersonUserId: change.actor.id,
transferredByUserId: after.ownerUserId,
});
await deps.projects().activity.record(tx.conn, {
issueId: after.id,
actor: change.actor,
action: 'studio_owner_accepted',
details: { ownerUserId: after.ownerUserId },
});
announceOwnerHandoff(tx, after, claimed, pending.notificationKey);
if (change.start) return [await takeOver(tx, after, change.actor)];
}
}
// The owner-change hook already withdrew/stopped work. The handoff controls when the new owner resumes it.
if (before && before.ownerUserId !== after.ownerUserId) {
if (
change.start &&
change.actor.type === 'user' &&
change.actor.via !== 'agent' &&
change.actor.id === after.ownerUserId
)
return [
await assigned(
tx,
after,
PROJECT_MANAGER_ID,
await actorOf(tx, change.actor, after),
'started',
),
];
return [];
}
const from = agentOf(before);
const to = agentOf(after);
if (
@@ -691,6 +798,28 @@ export function createAgentWork(deps: {
(!before || dormant(before) || to !== from)
)
requireOwnerStart(after, change.actor);
if (await ownerHandoffWaiting(tx.conn, after)) {
if (
change.start &&
change.actor.type === 'user' &&
change.actor.via !== 'agent' &&
after.statusKey === IN_PROGRESS_STATUS &&
before?.statusKey !== IN_PROGRESS_STATUS
)
throw studioError(
'FAILED_PRECONDITION',
'ISSUE_OWNER_HANDOFF_PENDING',
'Accept the owner handoff before starting this issue.',
);
return [
skipped(
to ?? PROJECT_MANAGER_ID,
after,
'statusChange',
'waitingOwner',
),
];
}
const who = await actorOf(tx, change.actor, after);
if (from && from !== to)
await stop(
@@ -762,6 +891,21 @@ export function createAgentWork(deps: {
async onCommentCreated(tx, change) {
const { comment, issue, parent } = change;
if (await ownerHandoffWaiting(tx.conn, issue)) {
const targets = new Set(
change.mentions
.filter((ref) => ref.kind === AGENT_KIND)
.map((ref) => ref.id),
);
const executor = agentOf(issue);
if (executor) targets.add(executor);
if (parent?.authorType === AGENT_KIND && parent.authorId)
targets.add(parent.authorId);
if (targets.size === 0) targets.add(PROJECT_MANAGER_ID);
return [...targets].map((id) =>
skipped(id, issue, 'comment', 'waitingOwner'),
);
}
if (statusCategory(issue.statusKey) === 'unstarted') return [];
const authorId = change.actor.id;
if (!authorId) return [];
@@ -832,6 +976,28 @@ export function createAgentWork(deps: {
async onOwnerChanged(tx, { issue, from, to, actor }) {
if (tx.rehearsal) return [];
const previous = await readOwnerHandoff(tx.conn, issue.id);
const source = actor?.trace?.runId
? await runs.identityInputs(tx.conn, actor.trace.runId)
: null;
const handoff = await recordOwnerHandoff(tx.conn, {
issueId: issue.id,
ownerUserId: to,
previousOwnerUserId: from,
transferredByUserId:
actor?.type === 'user'
? (actor.id ?? from)
: (source?.run.actorUserId ?? from),
authenticatedPersonUserId:
actor?.type === 'user' && actor.via !== 'agent' ? actor.id : null,
});
await deps.projects().activity.record(tx.conn, {
issueId: issue.id,
actor: actor ?? { type: 'system', id: null },
action: 'studio_owner_transferred',
details: { from, to, waiting: handoff.waiting },
});
announceOwnerHandoff(tx, issue, handoff, previous?.notificationKey);
const subject = subjectOf(issue);
const who = await actorOf(
tx,
@@ -861,6 +1027,14 @@ export function createAgentWork(deps: {
for (const run of await runs.openOn(tx.conn, subject))
if (run.status !== 'queued')
await runs.cancel(run.id, who.userId, agentsTx(tx));
if (!handoff.waiting) {
await deps.projects().activity.record(tx.conn, {
issueId: issue.id,
actor: actor ?? { type: 'system', id: null },
action: 'studio_owner_accepted',
details: { ownerUserId: to },
});
}
return [];
},
@@ -884,6 +1058,15 @@ export function createAgentWork(deps: {
},
async onUnblocked(tx, { issue, releasedBy }) {
if (await ownerHandoffWaiting(tx.conn, issue))
return [
skipped(
agentOf(issue) ?? PROJECT_MANAGER_ID,
issue,
'unblocked',
'waitingOwner',
),
];
const agentId = agentOf(issue);
if (!agentId) return [];
if (dormant(issue))
@@ -905,6 +1088,15 @@ export function createAgentWork(deps: {
},
async onSubtasksFinished(tx, { parent, stage, childIssueIds }) {
if (await ownerHandoffWaiting(tx.conn, parent))
return [
skipped(
agentOf(parent) ?? PROJECT_MANAGER_ID,
parent,
'subtasksFinished',
'waitingOwner',
),
];
const agentId = agentOf(parent);
if (!agentId) return [];
if (dormant(parent))
+13 -2
View File
@@ -44,6 +44,7 @@ import { ISSUE_SUBJECT } from '../agents/catalog/triggers.js';
import { AGENT_KIND, agentsTx } from '../agents/tx.js';
import { validIssueOwner } from '../agents/execution-identity.js';
import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js';
import { readOwnerHandoff } from '../issues/owner-handoff.js';
import type { StudioInboxPort } from '../inbox/port.js';
import {
IN_REVIEW,
@@ -368,10 +369,20 @@ export function createGitFlow(deps: GitFlowDeps): GitFlow {
if (
!issue ||
issue.status.category === 'done' ||
issue.status.category === 'closed' ||
issue.executor?.type !== AGENT_KIND
issue.status.category === 'closed'
)
continue;
if ((await readOwnerHandoff(tx.conn, issue.id))?.waiting) {
await deps.projects().activity.record(tx.conn, {
issueId: issue.id,
actor: { type: 'system', id: null },
action: 'work_skipped',
details: { reason: 'waitingOwner', trigger: signal },
});
tx.emit({ type: 'issue.changed', issueId: issue.id });
continue;
}
if (issue.executor?.type !== AGENT_KIND) continue;
if (signal !== 'passed' && count > SIGNAL_WAKE_LIMIT) {
const port = deps.inbox();
const card = await cardIssue(tx.conn, issue.id);
@@ -0,0 +1,79 @@
import type { Agents } from '@nocobase/app-plugin-agents/server/tokens';
import type {
Projects,
Viewer,
} from '@nocobase/app-plugin-projects/server/tokens';
import { createAgentWork } from '../agents/work.js';
import { studioError } from '../http/errors.js';
import { decideOwnerHandoff, readOwnerHandoff } from './owner-handoff.js';
import { announceOwnerHandoff } from './owner-handoff-notices.js';
export function createOwnerHandoffApi(projects: Projects, agents: Agents) {
const work = createAgentWork({ agents, projects: () => projects });
return {
async read(viewer: Viewer, key: string, personUserId: string | null) {
const issue = await projects.issueQueries.detail(viewer, key);
const handoff = await readOwnerHandoff(projects.tx.read(), issue.id);
const canClaim = Boolean(
handoff?.waiting &&
handoff.ownerUserId === issue.ownerUserId &&
personUserId === issue.ownerUserId &&
viewer.permissions.scopes['pm.issues/edit'] !== 'none',
);
return {
handoff,
canClaim,
canDecide: Boolean(
handoff?.waiting &&
!handoff.rejectedAt &&
handoff.ownerUserId === issue.ownerUserId &&
personUserId === issue.ownerUserId &&
viewer.permissions.scopes['pm.issues/edit'] !== 'none',
),
};
},
async decide(
viewer: Viewer,
key: string,
personUserId: string | null,
notificationKey: string,
decision: 'accept' | 'reject',
) {
if (
!personUserId ||
viewer.actor.type !== 'user' ||
viewer.permissions.scopes['pm.issues/edit'] === 'none'
)
throw studioError(
'PERMISSION_DENIED',
'ISSUE_OWNER_HANDOFF_REQUIRED',
'Only a person with issue editing permission may decide this handoff.',
);
return projects.tx.unit(async (tx) => {
const issue = await projects.issueQueries.detail(viewer, key);
const handoff = await decideOwnerHandoff(tx.conn, {
issue,
authenticatedPersonUserId: personUserId,
notificationKey,
decision,
});
await projects.activity.record(tx.conn, {
issueId: issue.id,
actor: viewer.actor,
action:
decision === 'accept'
? 'studio_owner_accepted'
: 'studio_owner_rejected',
details: { ownerUserId: issue.ownerUserId },
});
announceOwnerHandoff(tx, issue, handoff, undefined, decision);
const run =
decision === 'accept'
? await work.takeOver(tx, issue, viewer.actor, viewer)
: null;
tx.emit({ type: 'issue.changed', issueId: issue.id });
return { handoff, runId: run?.runId ?? null };
});
},
};
}
@@ -0,0 +1,91 @@
import type {
NoticeRule,
ProjectsTx,
} from '@nocobase/app-plugin-projects/server/tokens';
import type { Issue } from '@nocobase/app-plugin-projects/shared/issues';
import type { StudioInboxPort } from '../inbox/port.js';
import {
ownerHandoffRejectionRecipients,
type OwnerHandoff,
} from './owner-handoff.js';
export const OWNER_HANDOFF_NOTICE = 'studio.ownerHandoff';
export interface HandoffAnnouncement {
readonly issue: Pick<Issue, 'id' | 'identifier' | 'title'>;
readonly handoff: OwnerHandoff;
readonly previousNotificationKey?: string;
readonly decision?: 'accept' | 'reject';
}
export const ownerHandoffNoticeKey = (key: string) => `owner-handoff:${key}`;
export function announceOwnerHandoff(
tx: ProjectsTx,
issue: HandoffAnnouncement['issue'],
handoff: OwnerHandoff,
previousNotificationKey?: string,
decision?: HandoffAnnouncement['decision'],
): void {
tx.emit({
type: 'work.announced',
kind: OWNER_HANDOFF_NOTICE,
payload: {
issue: { id: issue.id, identifier: issue.identifier, title: issue.title },
handoff,
previousNotificationKey,
decision,
} satisfies HandoffAnnouncement,
});
}
export function ownerHandoffNoticeRule(): NoticeRule {
return async ({ events }) =>
events.flatMap((event) => {
if (
event.type !== 'work.announced' ||
event.kind !== OWNER_HANDOFF_NOTICE
)
return [];
const { issue, handoff, decision } = event.payload as HandoffAnnouncement;
if (decision === 'accept' || (!decision && !handoff.waiting)) return [];
const rejected = decision === 'reject';
const title = rejected
? `Owner handoff rejected: ${issue.identifier}`
: `Take over ${issue.identifier}`;
const body = rejected
? 'The owner declined the handoff. The issue remains paused.'
: 'Accept or reject the handoff on the issue. Agent work is paused until you accept.';
return [
{
key: `${ownerHandoffNoticeKey(handoff.notificationKey)}${rejected ? ':rejected' : ''}`,
kind: rejected ? ('info' as const) : ('decision' as const),
type: rejected ? 'owner_handoff_rejected' : 'owner_handoff',
issue,
userIds: rejected
? ownerHandoffRejectionRecipients(handoff)
: [handoff.ownerUserId],
actor: { type: 'system' as const, id: null, name: 'Studio' },
slot: 'change' as const,
params: {
title,
body,
identifier: issue.identifier,
notificationKey: handoff.notificationKey,
},
},
];
});
}
export async function settleOwnerHandoffNotice(
port: StudioInboxPort,
notice: HandoffAnnouncement,
): Promise<void> {
if (notice.previousNotificationKey)
await port.withdraw({
source: 'projects',
decisionKey: ownerHandoffNoticeKey(notice.previousNotificationKey),
});
if (notice.decision)
await port.resolve({
source: 'projects',
decisionKey: ownerHandoffNoticeKey(notice.handoff.notificationKey),
outcome: notice.decision === 'accept' ? 'accepted' : 'rejected',
});
}
@@ -0,0 +1,141 @@
import { agentsToken } from '@nocobase/app-plugin-agents/server/tokens';
import { authenticationToken } from '@nocobase/app-plugin-authentication';
import { authorizationToken } from '@nocobase/app-plugin-authorization';
import {
projectsAccessToken,
projectsToken,
} from '@nocobase/app-plugin-projects/server/tokens';
import type { Application } from '@nocobase/app-server/application';
import {
apiErrorResponse,
apiErrorResponses,
apiValidator,
cliRoute,
dataResponse,
defineApiRoutes,
describeRoute,
type AppApiRouteContribution,
} from '@nocobase/app-server/router';
import { Hono, type Context } from 'hono';
import { z } from 'zod';
import { createPermissionSource } from '../agents/commands/permissions.js';
import { createAskerLookup } from '../agents/conversation/acting.js';
import { callerOfRequest } from '../agents/run-principal.js';
import { studioError, studioErrorHandler } from '../http/errors.js';
import { createOwnerHandoffApi } from './owner-handoff-api.js';
const Params = z.object({ issueId: z.string().min(1) });
const Input = z.strictObject({ notificationKey: z.string().uuid() });
const Handoff = z.object({
issueId: z.string(),
waiting: z.boolean(),
ownerUserId: z.string(),
previousOwnerUserId: z.string(),
transferredByUserId: z.string(),
notificationKey: z.string(),
transferredAt: z.string(),
rejectedAt: z.string().nullable(),
});
export const ownerHandoffRoutes: AppApiRouteContribution<Application> =
defineApiRoutes(({ container }) => {
const agents = container.resolve(agentsToken);
const projects = container.resolve(projectsToken);
const permissions = createPermissionSource(
() => container.resolve(projectsAccessToken),
createAskerLookup(agents),
);
const api = createOwnerHandoffApi(projects, agents);
const router = new Hono();
router.onError(studioErrorHandler);
router.use(
'*',
container.resolve(authenticationToken).required({ scopedKeys: true }),
);
router.use('*', container.resolve(authorizationToken).middleware());
router.use('*', async (context, next) => {
const identity = callerOfRequest(context as unknown as Context);
const action =
context.req.method === 'GET' ? 'pm.issues/view' : 'pm.issues/edit';
if (!identity || !(await agents.gate.allowed(identity)).has(action))
throw studioError(
'PERMISSION_DENIED',
'ACTION_FORBIDDEN',
'This action is not allowed.',
);
if (context.req.method !== 'GET' && identity.kind !== 'user')
throw studioError(
'PERMISSION_DENIED',
'ISSUE_OWNER_HANDOFF_REQUIRED',
'An agent cannot consent to an owner handoff.',
);
await next();
});
router.get(
'/:issueId',
describeRoute({
tags: ['Studio'],
operationId: 'issueOwnerHandoffGet',
summary: 'Get an issue owner handoff',
...cliRoute(false),
security: [{ cookieAuth: [] }, { apiKeyAuth: [] }],
responses: {
200: dataResponse(
z.object({
handoff: Handoff.nullable(),
canDecide: z.boolean(),
canClaim: z.boolean(),
}),
),
404: apiErrorResponse(404, 'Issue not found.'),
...apiErrorResponses,
},
}),
apiValidator('param', Params),
async (context) => {
const identity = callerOfRequest(context as unknown as Context)!;
return context.json({
data: await api.read(
await permissions.viewerOf(identity),
context.req.valid('param').issueId,
identity.kind === 'user' ? identity.userId : null,
),
});
},
);
for (const decision of ['accept', 'reject'] as const)
router.post(
`/:issueId/${decision}`,
describeRoute({
tags: ['Studio'],
operationId: `issueOwnerHandoff${decision === 'accept' ? 'Accept' : 'Reject'}`,
summary: `${decision === 'accept' ? 'Accept' : 'Reject'} an issue owner handoff`,
...cliRoute(false),
security: [{ cookieAuth: [] }, { apiKeyAuth: [] }],
description:
'Only the current owner in person, with issue editing permission, may decide the current handoff. Rejection keeps the issue paused. A stale notification returns a conflict.',
responses: {
200: dataResponse(
z.object({ handoff: Handoff, runId: z.string().nullable() }),
),
409: apiErrorResponse(409, 'The handoff is no longer current.'),
404: apiErrorResponse(404, 'Issue not found.'),
...apiErrorResponses,
},
}),
apiValidator('param', Params),
apiValidator('json', Input),
async (context) => {
const identity = callerOfRequest(context as unknown as Context)!;
return context.json({
data: await api.decide(
await permissions.viewerOf(identity),
context.req.valid('param').issueId,
identity.kind === 'user' ? identity.userId : null,
context.req.valid('json').notificationKey,
decision,
),
});
},
);
return new Hono().route('/issueOwnerHandoffs', router);
});
@@ -0,0 +1,142 @@
/** One current handoff per issue; this is not an execution authorization or a request history. */
import { randomUUID } from 'node:crypto';
import type { DatabaseConnection } from '@nocobase/db';
import { studioError } from '../http/errors.js';
export const OWNER_HANDOFFS = 'studioIssueOwnerHandoffs';
export interface OwnerHandoff {
readonly issueId: string;
readonly waiting: boolean;
readonly ownerUserId: string;
readonly previousOwnerUserId: string;
readonly transferredByUserId: string;
/** Correlates an inbox item with the current transfer, including a transfer back to the same owner. */
readonly notificationKey: string;
readonly transferredAt: string;
readonly rejectedAt: string | null;
}
export async function readOwnerHandoff(
conn: DatabaseConnection,
issueId: string,
): Promise<OwnerHandoff | null> {
return (
(await conn.repository<OwnerHandoff>(OWNER_HANDOFFS).findOne({
filter: { issueId },
})) ?? null
);
}
/** Write in the owner change's transaction, before any wake or synchronous cancellation callback. */
export async function recordOwnerHandoff(
conn: DatabaseConnection,
change: {
readonly issueId: string;
readonly ownerUserId: string;
readonly previousOwnerUserId: string;
readonly transferredByUserId: string;
/** Null for a run or an automatic change, even when it acts for the target owner. */
readonly authenticatedPersonUserId: string | null;
},
): Promise<OwnerHandoff> {
const handoff: OwnerHandoff = {
issueId: change.issueId,
ownerUserId: change.ownerUserId,
previousOwnerUserId: change.previousOwnerUserId,
transferredByUserId: change.transferredByUserId,
waiting: change.authenticatedPersonUserId !== change.ownerUserId,
notificationKey: randomUUID(),
transferredAt: new Date().toISOString(),
rejectedAt: null,
};
await conn.repository(OWNER_HANDOFFS).upsertOne({
filter: { issueId: change.issueId },
create: { ...handoff },
update: { ...handoff },
});
return handoff;
}
/** A stale row never grants consent for a different current owner. */
export async function ownerHandoffWaiting(
conn: DatabaseConnection,
issue: { readonly id: string; readonly ownerUserId: string },
): Promise<boolean> {
const handoff = await readOwnerHandoff(conn, issue.id);
return (
handoff !== null &&
(handoff.waiting || handoff.ownerUserId !== issue.ownerUserId)
);
}
/**
* The caller has already checked issue access in this transaction. A route must pass the actual human identity,
* never a run's mapped userId. Decisions, activities and manager wakes belong to the same caller transaction.
*/
export async function decideOwnerHandoff(
conn: DatabaseConnection,
input: {
readonly issue: { readonly id: string; readonly ownerUserId: string };
readonly authenticatedPersonUserId: string | null;
readonly notificationKey: string;
readonly decision: 'accept' | 'reject';
},
): Promise<OwnerHandoff> {
const { issue } = input;
if (
!input.authenticatedPersonUserId ||
input.authenticatedPersonUserId !== issue.ownerUserId
)
throw studioError(
'PERMISSION_DENIED',
'ISSUE_OWNER_HANDOFF_REQUIRED',
'Only the current issue owner may accept or reject this handoff in person.',
);
const current = await readOwnerHandoff(conn, issue.id);
if (
!current?.waiting ||
current.rejectedAt !== null ||
current.ownerUserId !== issue.ownerUserId ||
current.notificationKey !== input.notificationKey
)
throw studioError(
'ABORTED',
'ISSUE_OWNER_HANDOFF_STALE',
'This handoff is no longer awaiting a decision. Refresh the issue.',
);
const next: OwnerHandoff = {
...current,
waiting: input.decision === 'reject',
rejectedAt: input.decision === 'reject' ? new Date().toISOString() : null,
};
const updated = await conn.repository(OWNER_HANDOFFS).updateMany({
filter: (f) =>
f.and([
f.string('issueId').eq(issue.id),
f.string('ownerUserId').eq(issue.ownerUserId),
f.string('notificationKey').eq(input.notificationKey),
f.boolean('waiting').isTrue(),
f.date('rejectedAt').empty(),
]),
values: { waiting: next.waiting, rejectedAt: next.rejectedAt },
});
if (updated.updatedCount !== 1)
throw studioError(
'ABORTED',
'ISSUE_OWNER_HANDOFF_STALE',
'This handoff is no longer awaiting a decision. Refresh the issue.',
);
return next;
}
/** Rejection informs both people, once each, without resuming any work. */
export function ownerHandoffRejectionRecipients(
handoff: OwnerHandoff,
): string[] {
return [
...new Set([handoff.previousOwnerUserId, handoff.transferredByUserId]),
];
}
@@ -2,6 +2,7 @@ import type { Application } from '@nocobase/app-server/application';
import type { AppRouteContribution } from '@nocobase/app-server/router';
import { accessRoutes } from '../access/routes.js';
import { ownerHandoffRoutes } from '../issues/owner-handoff-routes.js';
import { ciRunRoutes } from '../builds/ci-run-routes.js';
import { buildsRoutes } from '../builds/routes.js';
import { studioAgentsRoutes } from '../agents/routes.js';
@@ -19,6 +20,7 @@ import { reportsRoutes } from '../reports/routes.js';
const routes: readonly AppRouteContribution<Application>[] = [
accessRoutes,
ownerHandoffRoutes,
inboxRoutes,
studioAgentsRoutes,
issueRunsRoutes,
@@ -82,6 +82,7 @@ export type IssueProgressState =
export interface IssueProgress {
readonly waiting?: {
readonly reason:
| 'waitingOwner'
| 'noOwner'
| 'notConfigured'
| 'noRunner'
@@ -117,6 +117,7 @@ import {
} from '../../server/agents/conversation/delegation.js';
import { issueRunsRoutes } from '../../server/agents/run-views.js';
import { projectManagerRoutes } from '../../server/agents/manager/routes.js';
import { ownerHandoffRoutes } from '../../server/issues/owner-handoff-routes.js';
import { followUpsRoutes } from '../../server/follow-ups/routes.js';
import { createProjectManager } from '../../server/agents/manager/service.js';
import { studioProjectManagerToken } from '../../server/agents/manager/token.js';
@@ -1215,6 +1216,10 @@ export async function createBridgeHarness(
await studioAgentsRoutes.createRouter(appLike as unknown as never),
);
api.route('/', await inboxRoutes.createRouter(appLike as unknown as never));
api.route(
'/',
await ownerHandoffRoutes.createRouter(appLike as unknown as never),
);
api.route(
'/',
await projectManagerRoutes.createRouter(appLike as unknown as never),
@@ -348,6 +348,7 @@ describe('manager and delegated identities', () => {
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'bob',
start: false,
});
expect((await h.agents.runs.get(run.id)).actorUserId).toBe('alice');
expect((await h.agents.runs.get(run.id)).cancelRequestedAt).not.toBeNull();
@@ -383,6 +384,7 @@ describe('manager and delegated identities', () => {
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'bob',
start: false,
executor: null,
});
const after = await runs(task.id);
@@ -586,6 +588,7 @@ describe('manager and delegated identities', () => {
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'bob',
start: false,
});
const retry = await h.agents.runs.retry(previous.id, 'bob');
expect(retry.actorUserId).toBe('bob');
@@ -0,0 +1,456 @@
// @vitest-environment node
import { RUNNER_ROUTES } from '@nocobase/agent-protocol';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createBridgeHarness, type BridgeHarness } from './bridge-harness.js';
import { readOwnerHandoff } from '../../server/issues/owner-handoff.js';
import { createAgentWork } from '../../server/agents/work.js';
import {
ownerHandoffNoticeRule,
OWNER_HANDOFF_NOTICE,
} from '../../server/issues/owner-handoff-notices.js';
import { connectedRepo } from '../git/helpers.js';
import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js';
let h: BridgeHarness;
beforeEach(async () => {
h = await createBridgeHarness();
for (const id of ['alice', 'bob', 'carol']) {
await h.addUser(id);
h.roles.set(id, 'admin');
}
await h.createAgent();
});
afterEach(async () => h.close());
const runs = (id: string) =>
h.agents.runs.list({ subjectKind: 'issue', subjectId: id });
const task = (statusKey = 'in_progress') =>
h.projects.issues.create(h.viewer('alice'), {
title: 'Handoff',
statusKey,
start: false,
});
async function transfer(id: string, to = 'bob', by = 'alice') {
const issue = await h.projects.issueQueries.detail(h.viewer(by), id);
return h.projects.issues.update(h.viewer(by), id, {
revision: issue.revision,
ownerUserId: to,
});
}
async function decide(
id: string,
decision: 'accept' | 'reject',
user = 'bob',
key?: string,
) {
const handoff = await readOwnerHandoff(h.projects.tx.read(), id);
return h.request('POST', `/issueOwnerHandoffs/${id}/${decision}`, {
user,
body: { notificationKey: key ?? handoff!.notificationKey },
});
}
describe('owner acceptance', () => {
it('withdraws all queued work, stops held work, skips comments and release events, and resumes only the manager with comment delta', async () => {
const issue = await task();
await h.request('POST', `/projectManager/issues/${issue.id}/start`, {
user: 'alice',
});
const original = await h.claimOne();
const worker = await h.createAgent({ name: 'Worker' });
await h.projects.comments.create(h.viewer('alice'), issue.id, {
content: `[@Worker](mention://agent/${worker}) queued step`,
});
const before = await runs(issue.id);
await transfer(issue.id);
expect(
(await h.agents.runs.get(original.run.id)).cancelRequestedAt,
).not.toBeNull();
expect(
(await runs(issue.id)).filter((run) => run.status === 'queued'),
).toEqual([]);
await h.projects.comments.create(h.viewer('carol'), issue.id, {
content: `[@Worker](mention://agent/${worker}) waiting-period-comment`,
});
const current = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
const work = createAgentWork({
agents: h.agents,
projects: () => h.projects,
});
await h.projects.tx.run(async (tx) => {
expect(
await work.onUnblocked!(tx, { issue: current, releasedBy: current }),
).toMatchObject([{ skipped: 'waitingOwner' }]);
expect(
await work.onSubtasksFinished!(tx, {
parent: current,
stage: null,
childIssueIds: [],
}),
).toMatchObject([{ skipped: 'waitingOwner' }]);
});
expect(await runs(issue.id)).toHaveLength(before.length);
expect(
(
await h.request('POST', `/projectManager/issues/${issue.id}/start`, {
user: 'bob',
})
).body.error.reason,
).toBe('ISSUE_OWNER_HANDOFF_PENDING');
expect(
(await h.request('GET', `/issueProgress/${issue.id}`, { user: 'alice' }))
.body.data.waiting,
).toMatchObject({ reason: 'waitingOwner', owner: { id: 'bob' } });
expect(
current.activities.some(
(activity) =>
activity.action === 'work_skipped' &&
activity.details.reason === 'waitingOwner',
),
).toBe(true);
await expect(
h.agents.runs.retry(
before.find((run) => run.status === 'queued')!.id,
'bob',
),
).rejects.toBeDefined();
expect((await decide(issue.id, 'accept')).status).toBe(200);
expect((await decide(issue.id, 'accept')).status).toBe(409);
const newRuns = (await runs(issue.id)).filter(
(run) => run.status === 'queued',
);
expect(newRuns).toHaveLength(1);
expect(newRuns[0]).toMatchObject({
agentId: PROJECT_MANAGER_ID,
actorUserId: 'bob',
});
await h.runner(RUNNER_ROUTES.cancelAck, original.run.id, {});
const claimed = await h.claimOne();
expect(claimed.run.id).toBe(newRuns[0].id);
expect(JSON.stringify(claimed)).toContain('waiting-period-comment');
});
it('keeps rejection paused and notifies the original owner and actual transfer user', async () => {
const issue = await task();
const events: Parameters<
ReturnType<typeof ownerHandoffNoticeRule>
>[0]['events'][number][] = [];
h.projects.events.on('work.announced', (event) => {
if (event.kind === OWNER_HANDOFF_NOTICE) events.push(event);
});
await transfer(issue.id, 'bob', 'carol');
expect((await decide(issue.id, 'reject')).status).toBe(200);
expect(
await readOwnerHandoff(h.projects.tx.read(), issue.id),
).toMatchObject({
waiting: true,
ownerUserId: 'bob',
rejectedAt: expect.any(String),
});
await h.projects.comments.create(h.viewer('alice'), issue.id, {
content: 'Do not resume yet',
});
expect(await runs(issue.id)).toEqual([]);
const notices = await h.projects.tx.run((tx) =>
ownerHandoffNoticeRule()({
tx,
events,
subscribers: async () => [],
nameOf: async () => null,
}),
);
expect(
notices.find((notice) => notice.type === 'owner_handoff')?.userIds,
).toEqual(['bob']);
expect(
notices.find((notice) => notice.type === 'owner_handoff_rejected')
?.userIds,
).toEqual(['alice', 'carol']);
expect(h.port.settled).toContainEqual({
decisionKey: `owner-handoff:${(await readOwnerHandoff(h.projects.tx.read(), issue.id))!.notificationKey}`,
outcome: 'rejected',
});
});
it('checks authentication, issue permission, actual human identity and stale notices including ABA', async () => {
const issue = await task();
await transfer(issue.id);
const first = (await readOwnerHandoff(h.projects.tx.read(), issue.id))!;
expect(
(
await h.request('POST', `/issueOwnerHandoffs/${issue.id}/accept`, {
body: { notificationKey: first.notificationKey },
})
).status,
).toBe(401);
expect((await decide(issue.id, 'accept', 'alice')).status).toBe(403);
h.roles.set('bob', 'none');
expect((await decide(issue.id, 'accept')).status).toBe(403);
h.roles.set('bob', 'admin');
const other = await h.projects.issues.create(h.viewer('bob'), {
title: 'Actual Bob run',
statusKey: 'in_progress',
start: false,
});
await h.request('POST', `/projectManager/issues/${other.id}/start`, {
user: 'bob',
});
const claimed = await h.claimOne();
expect(
(
await h.request('POST', `/issueOwnerHandoffs/${issue.id}/accept`, {
runToken: claimed.cli.credential.content.token,
body: { notificationKey: first.notificationKey },
})
).status,
).toBe(403);
await transfer(issue.id, 'carol');
await transfer(issue.id, 'bob');
expect(
(await decide(issue.id, 'accept', 'bob', first.notificationKey)).status,
).toBe(409);
expect(
(await readOwnerHandoff(h.projects.tx.read(), issue.id))!.waiting,
).toBe(true);
});
it('does not replace the first start when accepting or claiming a backlog task', async () => {
const issue = await task('backlog');
await transfer(issue.id);
const pending = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
await expect(
h.projects.issues.update(h.viewer('bob'), issue.id, {
revision: pending.revision,
statusKey: 'in_progress',
}),
).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_PENDING' });
expect(
(await h.projects.issueQueries.detail(h.viewer('bob'), issue.id))
.statusKey,
).toBe('backlog');
expect((await decide(issue.id, 'accept')).status).toBe(200);
await h.projects.comments.create(h.viewer('bob'), issue.id, {
content: 'Still requires start',
});
expect(await runs(issue.id)).toEqual([]);
await transfer(issue.id, 'alice', 'alice');
expect(
(await readOwnerHandoff(h.projects.tx.read(), issue.id))!.waiting,
).toBe(false);
expect(await runs(issue.id)).toEqual([]);
});
it('allows a human owner to claim their already assigned, rejected handoff', async () => {
const issue = await task();
await transfer(issue.id);
await decide(issue.id, 'reject');
await transfer(issue.id, 'bob', 'bob');
expect(await runs(issue.id)).toHaveLength(1);
expect(
(await readOwnerHandoff(h.projects.tx.read(), issue.id))!.waiting,
).toBe(false);
await h.projects.comments.create(h.viewer('alice'), issue.id, {
content: 'Continue after the claim',
});
expect((await runs(issue.id))[0]).toMatchObject({
agentId: PROJECT_MANAGER_ID,
actorUserId: 'bob',
});
});
it.each(['pending', 'rejected'] as const)(
'clears an unassigned %s handoff on a human claim and resumes only the owner manager',
async (state) => {
const issue = await task();
await h.projects.issues.update(h.viewer('alice'), issue.id, {
revision: issue.revision,
ownerUserId: 'bob',
executor: null,
});
if (state === 'rejected') await decide(issue.id, 'reject');
const before = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
const response = await h.request(
'PATCH',
`/projects/issues/${issue.id}`,
{
user: 'bob',
body: { revision: before.revision, ownerUserId: 'bob' },
},
);
expect(response.status).toBe(200);
const returned = response.body.data.issue;
const stored = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
expect(returned.revision).toBe(stored.revision);
expect(returned.executor).toEqual(stored.executor);
const edited = await h.request('PATCH', `/projects/issues/${issue.id}`, {
user: 'bob',
body: { revision: returned.revision, title: 'Immediate edit' },
});
expect(edited.status).toBe(200);
expect(
await readOwnerHandoff(h.projects.tx.read(), issue.id),
).toMatchObject({ waiting: false, rejectedAt: null });
const after = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
expect(after.executor).toEqual({ type: 'agent', id: PROJECT_MANAGER_ID });
expect(await runs(issue.id)).toMatchObject([
{ agentId: PROJECT_MANAGER_ID, actorUserId: 'bob', status: 'queued' },
]);
expect(await runs(issue.id)).toHaveLength(1);
expect((await decide(issue.id, 'accept')).status).toBe(409);
await transfer(issue.id, 'bob', 'bob');
expect(await runs(issue.id)).toHaveLength(1);
},
);
it('does not let another person or an agent claim an unassigned handoff for its owner', async () => {
const issue = await task();
await h.projects.issues.update(h.viewer('alice'), issue.id, {
revision: issue.revision,
ownerUserId: 'bob',
executor: null,
});
for (const viewer of [
h.viewer('alice'),
{
...h.viewer('bob'),
actor: { type: 'user', id: 'bob', via: 'agent' } as const,
},
]) {
const current = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
await h.projects.issues.update(viewer, issue.id, {
revision: current.revision,
ownerUserId: 'bob',
});
expect(
await readOwnerHandoff(h.projects.tx.read(), issue.id),
).toMatchObject({ waiting: true });
expect(await runs(issue.id)).toEqual([]);
}
});
it('clears an unassigned backlog handoff without replacing the first start', async () => {
const issue = await task('backlog');
await h.projects.issues.update(h.viewer('alice'), issue.id, {
revision: issue.revision,
ownerUserId: 'bob',
executor: null,
});
await transfer(issue.id, 'bob', 'bob');
expect(
await readOwnerHandoff(h.projects.tx.read(), issue.id),
).toMatchObject({ waiting: false, rejectedAt: null });
expect(await runs(issue.id)).toEqual([]);
await h.projects.comments.create(h.viewer('bob'), issue.id, {
content: 'Still not started',
});
expect(await runs(issue.id)).toEqual([]);
});
it('keeps the pause when the executor is cleared and restores the manager on acceptance', async () => {
const issue = await task();
await h.projects.issues.update(h.viewer('alice'), issue.id, {
revision: issue.revision,
ownerUserId: 'bob',
executor: null,
});
await h.projects.comments.create(h.viewer('alice'), issue.id, {
content: 'Comment with no executor',
});
const before = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
expect(
before.activities.some(
(activity) =>
activity.action === 'work_skipped' &&
activity.details.reason === 'waitingOwner',
),
).toBe(true);
expect((await decide(issue.id, 'accept')).status).toBe(200);
const after = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
expect(after.executor).toEqual({ type: 'agent', id: PROJECT_MANAGER_ID });
expect((await runs(issue.id))[0]).toMatchObject({
actorUserId: 'bob',
agentId: PROJECT_MANAGER_ID,
});
});
it('refuses a legacy queued attempt returned after transfer at claim time', async () => {
const issue = await task();
await h.request('POST', `/projectManager/issues/${issue.id}/start`, {
user: 'alice',
});
const [run] = await runs(issue.id);
await transfer(issue.id);
await h.database
.connection()
.repository('agRuns')
.updateOne({
filter: { id: run.id },
values: {
status: 'queued',
attempt: 2,
dispatchedAt: new Date().toISOString(),
},
});
expect(await h.claimOne()).toBeUndefined();
expect((await h.agents.runs.get(run.id)).status).toBe('cancelled');
});
it('keeps CI and PR signals in activity while skipping agent work', async () => {
const issue = await task();
const repo = await connectedRepo(h, 'acme/handoff');
h.github.addPull('acme/handoff', {
number: 1,
head: { ref: `agent/${issue.identifier}`, sha: 'head1' },
});
await h.git.link(
h.viewer('alice'),
issue.id,
'https://github.com/acme/handoff/pull/1',
{ type: 'user', id: 'alice' },
);
await transfer(issue.id);
h.github.setStatus('acme/handoff', 'head1', 'failure');
await h.git.pollRepo(repo);
h.github.pull('acme/handoff', 1).mergeable_state = 'dirty';
await h.git.pollRepo(repo);
h.github.setStatus('acme/handoff', 'head1', 'success');
await h.git.pollRepo(repo);
expect(
(await runs(issue.id)).filter((run) => run.status === 'queued'),
).toEqual([]);
const detail = await h.projects.issueQueries.detail(
h.viewer('bob'),
issue.id,
);
expect(
detail.activities.filter(
(activity) =>
activity.action === 'work_skipped' &&
activity.details.reason === 'waitingOwner',
).length,
).toBeGreaterThanOrEqual(3);
});
});
@@ -7,6 +7,10 @@ import { expect, it, vi } from 'vitest';
import { AppLayout } from '../../client/layouts/app-layout.js';
vi.mock('../../client/inbox/navigation.js', () => ({
useInboxNavigation: () => ({ badge: null, label: 'Inbox', hint: 'Inbox' }),
}));
// The agents' chat needs services these tests do not provide; the agent-chat block is tested in the UI Library.
vi.mock(
'@nocobase/app-plugin-agents/client/chat',
@@ -237,6 +237,34 @@ const card = (rail: HTMLElement, identifier: string) =>
rail.querySelector<HTMLElement>(`[data-issue="${identifier}"]`)!;
describe('My follow-ups', () => {
it('keeps handoff activities readable after sharing the issue activity wording', async () => {
const original = mocks.issue.getMockImplementation()!;
mocks.issue.mockImplementation(async (id: string) => ({
...(await original(id)),
activities: [
'studio_owner_transferred',
'studio_owner_accepted',
'studio_owner_rejected',
].map((action, index) => ({
id: `handoff-${index}`,
actorType: 'user',
actorId: 'alice',
actorName: 'Alice',
action,
details: action === 'work_skipped' ? { reason: 'waitingOwner' } : {},
via: null,
createdAt: at(index + 4),
})),
}));
await mount();
const panel = await screen.findByTestId('follow-ups-activity');
await waitFor(() =>
expect(panel.textContent).toContain('transferred responsibility'),
);
expect(panel.textContent).toContain('accepted responsibility');
expect(panel.textContent).toContain('rejected the handoff');
});
it('shows the followed issues, what needs the viewer and what is new, with the first one open', async () => {
const rail = await mount();
const first = card(rail, 'WEB-5');
@@ -1,104 +0,0 @@
/**
* The header's inbox button: the count of decisions waiting on the viewer (99+ at most), the unread count when none
* waits, a label and tab title that say which, and the active state on the inbox itself.
*/
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
import { InboxHeaderButton } from '../../client/inbox/header-button';
const state = { decision: 0, unread: 0 };
vi.mock('../../client/inbox/hooks.js', () => ({
useInboxRefresh: () => undefined,
usePendingDecisions: () => ({ data: { decision: state.decision } }),
useUnreadCount: () => ({ data: state.unread }),
}));
vi.mock('../../client/inbox/use-registry.js', () => ({
usePendingFeeds: () => [],
}));
vi.mock('../../client/inbox/chime.js', () => ({
armInboxChime: () => undefined,
playInboxChime: () => undefined,
useInboxChime: () => ({ enabled: false }),
}));
// Keys stand for their text, with the count appended, so a test can tell which label was chosen and with what count.
vi.mock('@nocobase/i18n/client', () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number }) =>
options?.count === undefined ? key : `${key}:${options.count}`,
}),
}));
function renderAt(path: string): void {
render(
<MemoryRouter initialEntries={[path]}>
<TooltipProvider>
<InboxHeaderButton />
</TooltipProvider>
</MemoryRouter>,
);
}
describe('the header inbox button', () => {
beforeEach(() => {
state.decision = 0;
state.unread = 0;
document.title = 'Studio';
});
it('links to the inbox and counts the decisions waiting, in its label and the tab title', () => {
state.decision = 3;
state.unread = 5;
renderAt('/issues');
const button = screen.getByRole('link', { name: 'inbox.headerPending:3' });
expect(button).toHaveAttribute('href', '/inbox');
expect(button).not.toHaveAttribute('aria-current');
const badge = screen.getByTestId('studio-inbox-badge');
expect(badge).toHaveTextContent('3');
expect(badge).toHaveAttribute('data-kind', 'decisions');
expect(document.title).toBe('(3) Studio');
});
it('caps the badge at 99+ while the label keeps the full count', () => {
state.decision = 120;
renderAt('/');
expect(screen.getByTestId('studio-inbox-badge')).toHaveTextContent('99+');
expect(
screen.getByRole('link', { name: 'inbox.headerPending:120' }),
).toBeVisible();
});
it('counts the unread items, apart from decisions, when none waits', () => {
state.unread = 2;
renderAt('/');
const badge = screen.getByTestId('studio-inbox-badge');
expect(badge).toHaveTextContent('2');
expect(badge).toHaveAttribute('data-kind', 'unread');
expect(
screen.getByRole('link', { name: 'inbox.headerUnread:2' }),
).toBeVisible();
expect(document.title).toBe('(2) Studio');
});
it('caps the unread count at 99+ too', () => {
state.unread = 250;
renderAt('/');
expect(screen.getByTestId('studio-inbox-badge')).toHaveTextContent('99+');
expect(
screen.getByRole('link', { name: 'inbox.headerUnread:250' }),
).toBeVisible();
expect(document.title).toBe('(99+) Studio');
});
it('shows neither with nothing waiting or unread, and is marked current on the inbox', () => {
renderAt('/inbox');
const button = screen.getByRole('link', { name: 'inbox.title' });
expect(button).toHaveAttribute('aria-current', 'page');
expect(screen.queryByTestId('studio-inbox-badge')).not.toBeInTheDocument();
expect(document.title).toBe('Studio');
});
});
@@ -0,0 +1,191 @@
import {
createTestI18nRuntime,
TestI18nProvider,
} from '@nocobase/i18n/testing';
import { render, screen, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { InboxIcon } from 'lucide-react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import locales from '../../client/locales/index.js';
import { SidebarProvider } from '../../client/components/ui/sidebar.js';
import { NavigationMenu } from '../../client/layouts/components/navigation-menu.js';
import { useInboxNavigation } from '../../client/inbox/navigation.js';
import {
routeKey,
type RouteNavigationItem,
} from '../../client/routing/route-navigation.js';
const state = {
decision: 0 as number | undefined,
unread: 0,
plans: 0,
chime: true,
};
const play = vi.hoisted(() => vi.fn());
const refresh = vi.hoisted(() => vi.fn());
vi.mock('../../client/inbox/hooks.js', () => ({
useInboxRefresh: () => refresh(),
usePendingDecisions: () => ({ data: { decision: state.decision } }),
useUnreadCount: () => ({ data: state.unread }),
}));
vi.mock('../../client/inbox/use-registry.js', () => ({
usePendingFeeds: () => [{ items: Array.from({ length: state.plans }) }],
}));
vi.mock('../../client/inbox/chime.js', () => ({
armInboxChime: () => undefined,
playInboxChime: play,
useInboxChime: () => ({ enabled: state.chime }),
}));
const item: RouteNavigationItem = {
route: {
id: 'inbox',
name: 'inbox',
path: '/inbox',
auth: 'required',
authz: 'skip',
packageName: 'studio',
source: 'application',
navigation: { title: 'navigation.inbox', icon: InboxIcon },
componentLoader: async () => ({ default: () => null }),
},
children: [],
};
function Shell({
collapsed = false,
settings = false,
}: {
collapsed?: boolean;
settings?: boolean;
}) {
const inbox = useInboxNavigation();
return (
<SidebarProvider open={!collapsed} onOpenChange={() => {}}>
{settings ? (
<h1>Settings</h1>
) : (
<NavigationMenu
items={[item]}
label='Navigation'
selectedKey={routeKey(item.route)}
decorations={{ inbox }}
/>
)}
</SidebarProvider>
);
}
async function setup(props = {}, locale = 'en-US') {
const runtime = await createTestI18nRuntime({
application: { namespace: 'studio', resources: locales },
locale,
});
const tree = (options: { collapsed?: boolean; settings?: boolean }) => (
<TestI18nProvider runtime={runtime} namespace='studio'>
<MemoryRouter>
<Shell {...options} />
</MemoryRouter>
</TestI18nProvider>
);
const view = render(tree(props));
return {
...view,
update: (options: { collapsed?: boolean; settings?: boolean }) =>
view.rerender(tree(options)),
runtime,
};
}
describe('inbox navigation', () => {
beforeEach(() => {
Object.assign(state, { decision: 0, unread: 0, plans: 0, chime: true });
play.mockClear();
refresh.mockClear();
document.title = 'Studio';
vi.stubGlobal('matchMedia', () => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
});
it('links to the inbox, marks it current and hides zero counts', async () => {
await setup();
const link = screen.getByRole('link', { name: 'Inbox' });
expect(link).toHaveAttribute('href', '/inbox');
expect(link).toHaveAttribute('aria-current', 'page');
expect(screen.queryByTestId('studio-inbox-badge')).toBeNull();
expect(document.title).toBe('Studio');
});
it.each([
[3, 5, 2, '5', 'decisions', 'Inbox, 5 waiting'],
[120, 5, 0, '99+', 'decisions', 'Inbox, 120 waiting'],
[0, 2, 0, '2', 'unread', 'Inbox, 2 unread'],
[0, 250, 0, '99+', 'unread', 'Inbox, 250 unread'],
])(
'counts decisions %s, unread %s and plans %s',
async (decision, unread, plans, text, kind, label) => {
Object.assign(state, { decision, unread, plans });
await setup();
expect(screen.getByRole('link', { name: label })).toBeVisible();
expect(screen.getByTestId('studio-inbox-badge')).toHaveTextContent(text);
expect(screen.getByTestId('studio-inbox-badge')).toHaveAttribute(
'data-kind',
kind,
);
expect(document.title).toBe(`(${text}) Studio`);
expect(play).not.toHaveBeenCalled();
},
);
it('keeps reminders alive across collapse and settings, ringing only for increases', async () => {
state.decision = 1;
const view = await setup();
view.update({ collapsed: true });
expect(screen.getByTestId('studio-inbox-badge')).toHaveTextContent('1');
expect(play).not.toHaveBeenCalled();
state.decision = 2;
view.update({ settings: true });
expect(document.title).toBe('(2) Studio');
expect(play).toHaveBeenCalledTimes(1);
view.update({});
expect(play).toHaveBeenCalledTimes(1);
state.plans = 1;
view.update({ settings: true });
expect(document.title).toBe('(3) Studio');
expect(play).toHaveBeenCalledTimes(2);
view.update({ collapsed: true });
expect(play).toHaveBeenCalledTimes(2);
state.plans = 0;
state.decision = 0;
state.unread = 4;
view.update({});
expect(document.title).toBe('(4) Studio');
state.unread = 0;
view.update({});
expect(screen.queryByTestId('studio-inbox-badge')).toBeNull();
state.chime = false;
state.decision = 3;
view.update({});
expect(play).toHaveBeenCalledTimes(2);
view.unmount();
expect(document.title).toBe('Studio');
});
it('does not chime on initial loading and translates the full count without remounting', async () => {
state.decision = undefined;
const view = await setup();
state.decision = 120;
view.update({ collapsed: true });
expect(play).not.toHaveBeenCalled();
await act(() => view.runtime.changeLanguage('zh-CN'));
expect(
screen.getByRole('link', { name: '收件箱,120 项待处理' }),
).toBeVisible();
expect(screen.getByTestId('studio-inbox-badge')).toHaveTextContent('99+');
});
});
@@ -0,0 +1,68 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
MemoryRouter,
Route,
Routes,
useLocation,
useNavigate,
} from 'react-router';
import { expect, it } from 'vitest';
import LandingPage from '../../client/pages/landing/index.js';
import HomeRedirect from '../../client/pages/home/redirect.js';
function Destination() {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output>{location.pathname + location.search + location.hash}</output>
<button onClick={() => navigate(-1)}>Back</button>
</>
);
}
it('preserves search and fragment under the deployment base and replaces the root history entry', async () => {
render(
<MemoryRouter
basename='/main'
initialEntries={[
'/main/dashboard',
'/main/?account=preferences&kind=decision#detail',
]}
initialIndex={1}
>
<Routes>
<Route path='/' element={<LandingPage />} />
<Route path='/inbox' element={<Destination />} />
<Route path='/dashboard' element={<Destination />} />
</Routes>
</MemoryRouter>,
);
expect(screen.getByRole('status')).toHaveTextContent(
'/inbox?account=preferences&kind=decision#detail',
);
await userEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(screen.getByRole('status')).toHaveTextContent('/dashboard');
});
it('redirects legacy home URLs to the chat composer with search and fragment, replacing history', async () => {
render(
<MemoryRouter
basename='/main'
initialEntries={['/main/inbox', '/main/home?account=preferences#detail']}
initialIndex={1}
>
<Routes>
<Route path='/home' element={<HomeRedirect />} />
<Route path='/chat' element={<Destination />} />
<Route path='/inbox' element={<Destination />} />
</Routes>
</MemoryRouter>,
);
expect(screen.getByRole('status')).toHaveTextContent(
'/chat?account=preferences#detail',
);
await userEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(screen.getByRole('status')).toHaveTextContent('/inbox');
});
@@ -35,9 +35,9 @@ vi.mock('../../client/theme/index.js', () => ({ ThemeSettings: () => null }));
vi.mock('../../client/layouts/components/user-menu.js', () => ({
UserMenu: () => null,
}));
// The header's inbox button needs the API and realtime; its own test covers its labels.
vi.mock('../../client/inbox/header-button.js', () => ({
InboxHeaderButton: () => null,
// Inbox navigation needs the API and realtime; its own test covers its labels.
vi.mock('../../client/inbox/navigation.js', () => ({
useInboxNavigation: () => ({ badge: null, label: 'Inbox', hint: 'Inbox' }),
}));
vi.mock('../../client/routing/route-navigation.js', async (importOriginal) => ({
...(await importOriginal<
@@ -0,0 +1,129 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
createTestI18nRuntime,
TestI18nProvider,
} from '@nocobase/i18n/testing';
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import enUS from '../../client/locales/en-US.js';
import zhCN from '../../client/locales/zh-CN.js';
import { OwnerHandoffActions } from '../../client/issues/detail/owner-handoff.js';
const mocks = vi.hoisted(() => ({ request: vi.fn(), show: vi.fn() }));
vi.mock('@nocobase/app-client', async (original) => ({
...(await original<typeof import('@nocobase/app-client')>()),
useApiClient: () => ({ request: mocks.request }),
useToaster: () => ({ show: mocks.show }),
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
async function mount(
canDecide = true,
expected?: string,
language: 'en-US' | 'zh-CN' = 'zh-CN',
rejected = false,
) {
mocks.request.mockResolvedValue({
data: {
canDecide,
canClaim: canDecide || rejected,
handoff: {
waiting: true,
notificationKey: 'current',
rejectedAt: rejected ? '2026-10-12T00:00:00Z' : null,
ownerUserId: 'bob',
},
},
});
const runtime = await createTestI18nRuntime({
application: {
namespace: 'studio',
resources: language === 'en-US' ? enUS : zhCN,
},
locale: language,
});
const query = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<TestI18nProvider runtime={runtime}>
<QueryClientProvider client={query}>
<OwnerHandoffActions issueId='i1' notificationKey={expected} />
</QueryClientProvider>
</TestI18nProvider>,
);
}
it.each(['en-US', 'zh-CN'] as const)(
'submits the current decision and renders both actions in %s',
async (language) => {
await mount(true, undefined, language);
const accept = await screen.findByRole('button', {
name: language === 'en-US' ? 'Take over' : '接手',
});
expect(
screen.getByRole('button', {
name: language === 'en-US' ? 'Reject' : '拒绝',
}),
).toBeDefined();
fireEvent.click(accept);
await waitFor(() =>
expect(mocks.request).toHaveBeenCalledWith({
method: 'POST',
path: 'issueOwnerHandoffs/i1/accept',
json: { notificationKey: 'current' },
}),
);
},
);
it('hides decisions for an unauthorized viewer and explains a stale inbox item', async () => {
await mount(false);
await waitFor(() => expect(mocks.request).toHaveBeenCalled());
expect(screen.queryByRole('button')).toBeNull();
cleanup();
await mount(true, 'old');
expect(
await screen.findByText('此接手请求已失效,请刷新任务。'),
).toBeDefined();
expect(screen.queryByRole('button')).toBeNull();
});
it('submits rejection without removing the waiting marker locally', async () => {
await mount();
fireEvent.click(await screen.findByRole('button', { name: '拒绝' }));
await waitFor(() =>
expect(mocks.request).toHaveBeenCalledWith({
method: 'POST',
path: 'issueOwnerHandoffs/i1/reject',
json: { notificationKey: 'current' },
}),
);
});
it('lets the owner explicitly claim a rejected handoff using the issue revision', async () => {
await mount(false, undefined, 'zh-CN', true);
const claim = await screen.findByRole('button', { name: '认领' });
mocks.request.mockImplementation(
(request: { method?: string; path: string }) =>
Promise.resolve({
data:
request.path === 'projects/issues/i1'
? { revision: 4 }
: { canDecide: false, handoff: null },
}),
);
fireEvent.click(claim);
await waitFor(() =>
expect(mocks.request).toHaveBeenCalledWith({
method: 'PATCH',
path: 'projects/issues/i1',
json: { revision: 4, ownerUserId: 'bob' },
}),
);
});
@@ -147,14 +147,16 @@ describe('RichTextEditor', () => {
);
it('inserts a mention chosen from the suggestion list, stored as a mention link', async () => {
const user = userEvent.setup();
const { editor, textbox, onChange } = renderEditor();
// Focus synchronously: Tiptap's focus command defers selection to an animation frame.
act(() => {
editor.commands.focus();
textbox.focus();
editor.commands.insertContent('Ask @Ad');
});
await screen.findByRole('option', { name: 'Ada Lovelace' });
expect(textbox.getAttribute('aria-expanded')).toBe('true');
fireEvent.keyDown(textbox, { key: 'Enter' });
await user.keyboard('{Enter}');
await waitFor(() =>
expect(onChange).toHaveBeenLastCalledWith(
'Ask [@Ada Lovelace](mention://user/u1) ',
@@ -165,8 +165,8 @@ describe('the role editor', () => {
.getAllByRole('switch')
.map((item) => item.closest('div')?.textContent),
).toEqual([
'navigation.dashboard',
'navigation.myIssues',
'navigation.dashboard',
'navigation.issues',
'navigation.projects',
'navigation.knowledge',
@@ -1224,7 +1224,7 @@ describe('push credentials on demand', () => {
const app = await appConnection();
const agentId = await h.createAgent({ actions: ['pm.issues/view'] });
const linked = await projectWithRepo(app, 'in_progress');
await h.projects.issues.update(alice(), linked.issue.id, {
linked.issue = await h.projects.issues.update(alice(), linked.issue.id, {
revision: linked.issue.revision,
executor: null,
start: false,
@@ -1268,9 +1268,9 @@ describe('push credentials on demand', () => {
it('lists nothing on demand for a token connection', async () => {
const connection = await tokenConnection(h);
const { issue } = await projectWithRepo(connection, 'in_progress');
await h.projects.issues.update(alice(), issue.id, {
revision: issue.revision,
const { issue: created } = await projectWithRepo(connection, 'in_progress');
const issue = await h.projects.issues.update(alice(), created.id, {
revision: created.revision,
executor: null,
start: false,
});
@@ -228,10 +228,14 @@ describe('required and reference checks', () => {
const agentId = await h.createAgent({ actions: ['pm.issues/view'] });
const issue = await h.projects.issues.create(alice(), {
title: 'Read checks',
executor: null,
statusKey: 'in_progress',
start: false,
});
await h.projects.issues.update(alice(), issue.id, {
revision: issue.revision,
executor: null,
start: false,
});
await repoWithReference();
h.github.addPull(REPO, { number: 9, head: { ref: 'logs', sha: 'logs1' } });
h.github.setCheck(
@@ -86,18 +86,17 @@ describe('app client routes', () => {
).toEqual([]);
});
it('orders the sidebar with Home and Dashboard first, then My follow-ups and My issues, the Development, Releases and Agent team sections, and Settings', () => {
// The inbox is not among them: it is the header's button (`client/inbox/header-button.tsx`).
it('orders My tasks, Inbox, Dashboard and My follow-ups without a Home entry, followed by the existing sections', () => {
const menu = (routes: readonly AppClientRegisteredRoute[]) =>
routes.flatMap((route) =>
route.navigation ? [route.navigation.title] : [],
);
const { routes } = resolveRoutes();
expect(menu(routes)).toEqual([
'navigation.home',
'navigation.myIssues',
'navigation.inbox',
'navigation.dashboard',
'navigation.followUps',
'navigation.myIssues',
'navigation.development',
'navigation.releases',
'navigation.agentTeam',
@@ -156,21 +155,15 @@ describe('app client routes', () => {
expect(new Set(own.map((page) => page.authorizedAs)).size).toBe(1);
}
expect(all.filter((page) => !teamNames.has(page.name))).toEqual([
{ name: 'landing', authorizedAs: null },
// Chat actions reach the composer; bookmarked home links redirect there.
{ name: 'chat-new', authorizedAs: null },
{ name: 'home', authorizedAs: null },
// One conversation full screen: a conversation is its owner's alone, which its API checks.
{ name: 'chat', authorizedAs: null },
// The person's own settings; every category's API checks what it serves.
{ name: 'account', authorizedAs: null },
{ name: 'account-category', authorizedAs: null },
// The viewer's own inbox, opened from the header rather than the menu.
{ name: 'inbox', authorizedAs: null },
// Legacy `/pm` links: redirects that open the agents' chat panel, which checks what it opens.
{ name: 'chat-pm', authorizedAs: null },
{ name: 'chat-pm-conversation', authorizedAs: null },
// The dashboard, behind the reports grant its figures' API checks too.
{ name: 'dashboard', authorizedAs: 'reports' },
// My follow-ups opens with My issues' grant.
{ name: 'follow-ups', authorizedAs: 'pm-my-issues' },
{ name: 'pm-my-issues', authorizedAs: 'pm-my-issues' },
{ name: 'pm-my-issues-owned', authorizedAs: 'pm-my-issues' },
// An issue opened from a list is declared under it, behind the list's grant (`issueDetailRoute`).
@@ -195,6 +188,14 @@ describe('app client routes', () => {
name: 'pm-my-issues-executing-issue-run',
authorizedAs: 'pm-my-issues',
},
// Legacy `/pm` links open the chat panel; the inbox needs no page grant.
{ name: 'chat-pm', authorizedAs: null },
{ name: 'chat-pm-conversation', authorizedAs: null },
{ name: 'inbox', authorizedAs: null },
// The dashboard, behind the reports grant its figures' API checks too.
{ name: 'dashboard', authorizedAs: 'reports' },
// My follow-ups opens with My issues' grant.
{ name: 'follow-ups', authorizedAs: 'pm-my-issues' },
{ name: 'pm-issues', authorizedAs: 'pm-issues' },
{ name: 'pm-issue-new', authorizedAs: 'pm-issues' },
{ name: 'pm-plan', authorizedAs: 'pm-issues' },
@@ -27,8 +27,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AppRouter } from '../../client/routing/app-router.tsx';
import { AppThemeProvider } from '../../client/theme/index.ts';
// The agents' chat and the header's inbox button need services these tests do not provide; the agent-chat block is
// tested in the UI Library and the button in `tests/components/inbox-header-button.test.tsx`.
// Chat and inbox navigation need services these tests do not provide; their dedicated tests cover those behaviors.
vi.mock(
'@nocobase/app-plugin-agents/client/chat',
() => import('../setup/agents-chat-stub.js'),
@@ -37,8 +36,8 @@ vi.mock(
'../../client/agents/chat.js',
() => import('../setup/agents-chat-stub.js'),
);
vi.mock('../../client/inbox/header-button.js', () => ({
InboxHeaderButton: () => null,
vi.mock('../../client/inbox/navigation.js', () => ({
useInboxNavigation: () => ({ badge: null, label: 'Inbox', hint: 'Inbox' }),
}));
/** The sidebar's navigation; the header's breadcrumb names the current page as well. */
@@ -0,0 +1,142 @@
// @vitest-environment node
import path from 'node:path';
import { describeMigration } from '@nocobase/app-testing/server';
import { expect } from 'vitest';
import {
decideOwnerHandoff,
ownerHandoffRejectionRecipients,
ownerHandoffWaiting,
readOwnerHandoff,
recordOwnerHandoff,
} from '../../server/issues/owner-handoff.js';
describeMigration('202610240040_studio_issue_owner_handoffs', {
sources: [
{
packageName: 'projects-issues-fixture',
directory: path.resolve(
import.meta.dirname,
'../fixtures/projects-issues',
),
},
{
packageName: '@nocobase/studio',
directory: path.resolve(
import.meta.dirname,
'../../database/main/migrations',
),
},
],
up: async ({ connection, expectCollection }) => {
await expectCollection('studioIssueOwnerHandoffs').toHaveField('waiting');
await expectCollection('studioIssueOwnerHandoffs').toHaveField(
'notificationKey',
);
const issue = { id: 'handoff', ownerUserId: 'bob' };
expect(await readOwnerHandoff(connection, issue.id)).toBeNull();
expect(await ownerHandoffWaiting(connection, issue)).toBe(false);
const transfer = {
issueId: issue.id,
ownerUserId: 'bob',
previousOwnerUserId: 'alice',
transferredByUserId: 'carol',
authenticatedPersonUserId: 'carol',
};
const first = await recordOwnerHandoff(connection, transfer);
expect(await ownerHandoffWaiting(connection, issue)).toBe(true);
expect(await readOwnerHandoff(connection, issue.id)).toMatchObject(first);
// A run acting for Bob cannot make Bob's decision, and Alice cannot decide for Bob.
for (const authenticatedPersonUserId of [null, 'alice']) {
await expect(
decideOwnerHandoff(connection, {
issue,
authenticatedPersonUserId,
notificationKey: first.notificationKey,
decision: 'accept',
}),
).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_REQUIRED' });
}
const rejected = await decideOwnerHandoff(connection, {
issue,
authenticatedPersonUserId: 'bob',
notificationKey: first.notificationKey,
decision: 'reject',
});
expect(rejected.waiting).toBe(true);
expect(rejected.ownerUserId).toBe('bob');
expect(rejected.rejectedAt).not.toBeNull();
expect(ownerHandoffRejectionRecipients(rejected)).toEqual([
'alice',
'carol',
]);
expect(
ownerHandoffRejectionRecipients({
...rejected,
transferredByUserId: 'alice',
}),
).toEqual(['alice']);
await expect(
decideOwnerHandoff(connection, {
issue,
authenticatedPersonUserId: 'bob',
notificationKey: first.notificationKey,
decision: 'accept',
}),
).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_STALE' });
// The current owner and current notice are checked independently: even an ABA transfer cannot reuse the old notice.
await recordOwnerHandoff(connection, { ...transfer, ownerUserId: 'dave' });
const latest = await recordOwnerHandoff(connection, transfer);
expect(latest.notificationKey).not.toBe(first.notificationKey);
await expect(
decideOwnerHandoff(connection, {
issue,
authenticatedPersonUserId: 'bob',
notificationKey: first.notificationKey,
decision: 'accept',
}),
).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_STALE' });
await expect(
decideOwnerHandoff(connection, {
issue: { ...issue, ownerUserId: 'dave' },
authenticatedPersonUserId: 'dave',
notificationKey: latest.notificationKey,
decision: 'accept',
}),
).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_STALE' });
const accepted = await decideOwnerHandoff(connection, {
issue,
authenticatedPersonUserId: 'bob',
notificationKey: latest.notificationKey,
decision: 'accept',
});
expect(accepted.waiting).toBe(false);
expect(await ownerHandoffWaiting(connection, issue)).toBe(false);
expect(
await ownerHandoffWaiting(connection, { ...issue, ownerUserId: 'dave' }),
).toBe(true);
const claimed = await recordOwnerHandoff(connection, {
...transfer,
authenticatedPersonUserId: 'bob',
transferredByUserId: 'bob',
});
expect(claimed.waiting).toBe(false);
// Agent and automatic assignment still need confirmation, even when the mapped user is the new owner.
expect(
(
await recordOwnerHandoff(connection, {
...transfer,
transferredByUserId: 'bob',
authenticatedPersonUserId: null,
})
).waiting,
).toBe(true);
},
down: async ({ expectCollection }) => {
await expectCollection('studioIssueOwnerHandoffs').not.toExist();
},
});
@@ -6,7 +6,7 @@ import { PAGES } from '../../shared/pages';
describe('the role editor’s page groups', () => {
it('follow the sidebar’s sections and order', () => {
expect(PAGE_GROUPS).toEqual([
{ title: null, pages: ['reports', 'pm-my-issues'] },
{ title: null, pages: ['pm-my-issues', 'reports'] },
{
title: 'navigation.development',
pages: ['pm-issues', 'pm-projects', 'knowledge'],
@@ -34,6 +34,8 @@ const colors = [
'popover-foreground',
'primary',
'primary-foreground',
'inbox-pending',
'inbox-pending-foreground',
'secondary',
'secondary-foreground',
'muted',
@@ -207,8 +207,11 @@ export function createIssueService(deps: IssueDeps): IssueService {
after: Issue,
actor: Actor,
start: boolean,
ownerClaimed = false,
): Promise<void> {
await deps.triggers().onIssueChanged(tx, { before, after, actor, start });
await deps
.triggers()
.onIssueChanged(tx, { before, after, actor, start, ownerClaimed });
await deps.relations().changed(tx, { before, after, actor });
tx.emit({ type: 'issue.changed', issueId: after.id });
for (const parentId of new Set([before.parentIssueId, after.parentIssueId]))
@@ -313,6 +316,11 @@ export function createIssueService(deps: IssueDeps): IssueService {
return deps.tx.run(async (tx) => {
const before = await requireVisible(tx.conn, viewer, idOrKey);
requireEditor(viewer);
const ownerClaimed =
patch.ownerUserId === before.ownerUserId &&
viewer.actor.type === 'user' &&
viewer.actor.via !== 'agent' &&
viewer.actor.id === before.ownerUserId;
// Status and project changes can release waits. Take the graph guard
// before this issue's first write, in the same order as relationship edits and plan execution.
if (
@@ -343,7 +351,7 @@ export function createIssueService(deps: IssueDeps): IssueService {
: { added: [], removed: [] };
if (labels.added.length > 0 || labels.removed.length > 0)
activities.push({ action: 'labels_changed', details: labels });
if (activities.length === 0) return;
if (activities.length === 0 && !ownerClaimed) return;
const now = new Date().toISOString();
await updateIssue(
tx.conn,
@@ -367,8 +375,16 @@ export function createIssueService(deps: IssueDeps): IssueService {
if (!written) return before;
const after = await reload(tx, before.id);
if (placed) await deps.relations().placed(tx, after);
await announce(tx, before, after, viewer.actor, patch.start !== false);
return after;
await announce(
tx,
before,
after,
viewer.actor,
patch.start !== false,
ownerClaimed,
);
// Work hooks may update the issue again within this transaction.
return reload(tx, before.id);
});
},
@@ -55,6 +55,8 @@ export interface StatusService {
}
export interface IssueChange {
/** A person explicitly assigned the issue to themselves, including an unchanged owner field. */
readonly ownerClaimed?: boolean;
/** Null for a new issue. */
readonly before: Issue | null;
readonly after: Issue;
@@ -56,6 +56,7 @@ declare module './events.js' {
* principals' comments started as many runs on the issue as the kind allows.
*/
export type RunAttemptSkip =
| 'waitingOwner'
| 'deferred'
| 'limited'
| 'denied'
@@ -96,6 +97,10 @@ export interface WorkWithdrawal {
* that transaction is a rehearsal (`tx.rehearsal`), it only answers what it would start.
*/
export interface IssueWorkHandler {
/** Receives comments even when the executor was cleared, for issue-wide pause policies. */
readonly observesComments?: boolean;
/** Receives trusted owner claims even when the executor is cleared. */
readonly observesOwnerClaims?: boolean;
/** A new issue, or an update (including a status moved by a status event). */
onIssueChanged(tx: Tx, change: IssueChange): Promise<readonly RunAttempt[]>;
/**
@@ -199,6 +204,7 @@ const blockerRefs = (blockers: readonly Blocker[]) =>
/** The skips recorded on the issue's activity. */
const RECORDED_SKIPS: ReadonlySet<string> = new Set([
'waitingOwner',
'blocked',
'dormant',
'limited',
@@ -264,12 +270,22 @@ export function kindTriggers(
for (const [, work] of handlersOf(kinds, [
before?.executor?.type,
after.executor?.type,
...(change.ownerClaimed
? kinds
.list()
.filter((kind) => kind.work?.observesOwnerClaims)
.map((kind) => kind.key)
: []),
]))
await report(tx, after, work.onIssueChanged(tx, change));
},
async onCommentCreated(tx, change) {
const concerned = handlersOf(kinds, [
...kinds
.list()
.filter((kind) => kind.work?.observesComments)
.map((kind) => kind.key),
change.issue.executor?.type,
change.parent?.authorType,
...change.mentions.map((ref) => ref.kind),
@@ -21,7 +21,13 @@ interface Seen {
}
/** A kind whose work handler records what it hears and starts work for every call, unless told to refuse. */
function addBots(options: { refuse?: boolean } = {}): Seen[] {
function addBots(
options: {
refuse?: boolean;
observesOwnerClaims?: boolean;
changed?: IssueWorkHandler['onIssueChanged'];
} = {},
): Seen[] {
const seen: Seen[] = [];
const attempt = (
subjectId: string,
@@ -38,7 +44,9 @@ function addBots(options: { refuse?: boolean } = {}): Seen[] {
},
];
const work: IssueWorkHandler = {
observesOwnerClaims: options.observesOwnerClaims,
onIssueChanged(_tx, change) {
if (options.changed) return options.changed(_tx, change);
if (options.refuse) return Promise.reject(new Error('No runner.'));
seen.push({
hook: 'changed',
@@ -86,6 +94,72 @@ function addBots(options: { refuse?: boolean } = {}): Seen[] {
return seen;
}
it('delivers only trusted owner claims to an opt-in work kind without an executor', async () => {
const seen = addBots({ observesOwnerClaims: true });
const issue = await h.services.issues.create(alice(), {
title: 'Claim',
executor: null,
});
seen.length = 0;
await h.services.issues.update(alice(), issue.id, {
revision: issue.revision,
title: 'Ordinary change',
});
expect(seen).toEqual([]);
const current = await h.services.issueQueries.detail(alice(), issue.id);
await h.services.issues.update(alice(), issue.id, {
revision: current.revision,
ownerUserId: 'alice',
});
expect(seen.filter((event) => event.hook === 'changed')).toHaveLength(1);
});
it('returns the final issue after ordinary updates and nested work-hook updates', async () => {
const calls: string[] = [];
addBots({
changed: async (tx, change) => {
calls.push(change.after.title);
if (change.after.title === 'Outer update')
await h.services.tx.savepoint(tx, () =>
h.services.issues.update(alice(), change.after.id, {
revision: change.after.revision,
title: 'Hook update',
start: false,
}),
);
return [];
},
});
const issue = await h.services.issues.create(alice(), {
title: 'Initial',
executor: { type: 'bot', id: 'b1' },
});
calls.length = 0;
const ordinary = await h.services.issues.update(alice(), issue.id, {
revision: issue.revision,
title: 'Ordinary update',
});
expect(ordinary).toMatchObject({
title: 'Ordinary update',
revision: issue.revision + 1,
});
const nested = await h.services.issues.update(alice(), issue.id, {
revision: ordinary.revision,
title: 'Outer update',
});
const stored = await h.services.issueQueries.detail(alice(), issue.id);
expect(nested).toMatchObject({
title: 'Hook update',
revision: ordinary.revision + 2,
});
expect(nested.revision).toBe(stored.revision);
expect(nested.title).toBe(stored.title);
expect(calls).toEqual(['Ordinary update', 'Outer update', 'Hook update']);
expect(
stored.activities.filter((entry) => entry.action === 'title_changed'),
).toHaveLength(3);
});
describe('work handlers of registered kinds', () => {
it('hears an issue given to its kind, in the same transaction, and reports the attempt', async () => {
const seen = addBots();
@@ -0,0 +1,37 @@
.auth-brand-panel {
background: var(--auth-brand-background);
background-image: radial-gradient(
ellipse at top right,
color-mix(in srgb, var(--auth-brand-highlight) 8%, transparent),
transparent 65%
);
}
/* Authentication surfaces have their own palette; application pages keep the selected theme. */
.dark .auth-page {
--background: #141923;
--foreground: #eef1f7;
--card: #1b2230;
--muted-foreground: #a4adbd;
--primary: #526bc7;
--primary-foreground: #ffffff;
--border: #30394b;
--input: #3b475c;
--ring: #8198ed;
--auth-brand-background: #192130;
--auth-brand-highlight: #a2b3ec;
--auth-brand-muted: #aab5c9;
}
.dark .auth-page > aside {
border-color: transparent;
}
.dark .auth-page [data-slot='input'],
.dark .auth-page [data-slot='input-group'] {
background-color: var(--card);
}
.dark .auth-page [data-slot='input-group'] input {
background-color: transparent;
}
@@ -5,6 +5,8 @@ import type { ReactElement, ReactNode } from 'react';
import { AuthSplitLayout } from '#extensions/nocobase-auth-split-layout/auth-split-layout';
import './auth-page.css';
export interface AuthPageProps {
readonly title: ReactNode;
readonly description: ReactNode;
@@ -26,6 +28,7 @@ export function AuthPage({
<AuthSplitLayout
aside={<BrandPanel />}
asideLabel={t('auth.about')}
className='auth-page'
description={description}
logo={
<>
@@ -68,42 +71,39 @@ function BrandPanel(): ReactElement {
];
return (
<div className='relative grid h-full place-items-center overflow-hidden bg-(--auth-brand-background) p-12 text-white'>
<div className='pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,color-mix(in_srgb,var(--primary)_22%,transparent),transparent_65%)]' />
<div className='relative w-full max-w-xl'>
<div className='auth-brand-panel relative grid h-full place-items-center overflow-hidden p-12 text-white'>
<div className='relative w-full max-w-md'>
<p className='text-xs font-semibold tracking-[0.16em] text-(--auth-brand-highlight) uppercase'>
{t('auth.platform')}
</p>
<h2 className='mt-4 text-5xl leading-[1.05] font-semibold tracking-[-0.045em]'>
<h2 className='mt-6 text-4xl leading-tight font-semibold tracking-tight'>
{t('auth.marketingTitleFirst')}
<br />
{t('auth.marketingTitleSecond')}
<br />
{t('auth.marketingTitleThird')}
</h2>
<p className='mt-5 text-sm leading-6 text-(--auth-brand-muted)'>
<p className='mt-6 text-sm leading-7 text-(--auth-brand-muted)'>
{t('auth.marketingDescription')}
</p>
<div className='mt-8 overflow-hidden rounded-2xl border border-white/10 bg-white/5 text-white shadow-lg backdrop-blur-sm'>
<div className='space-y-5 p-6'>
{features.map(({ description, icon: Icon, id, title }) => (
<div className='flex gap-4' key={id}>
<span className='grid size-11 shrink-0 place-items-center rounded-xl bg-(--auth-brand-highlight)/10 text-(--auth-brand-highlight)'>
<Icon aria-hidden='true' className='size-5' />
</span>
<div>
<p className='font-semibold'>{title}</p>
<p className='mt-1 text-sm leading-6 text-(--auth-brand-muted)'>
{description}
</p>
</div>
<div className='mt-12 space-y-8'>
{features.map(({ description, icon: Icon, id, title }) => (
<div className='flex gap-4' key={id}>
<span className='grid size-10 shrink-0 place-items-center rounded-xl bg-(--auth-brand-highlight)/8 text-(--auth-brand-highlight)'>
<Icon aria-hidden='true' className='size-5' />
</span>
<div>
<p className='text-sm font-medium'>{title}</p>
<p className='mt-1.5 text-sm leading-6 text-(--auth-brand-muted)'>
{description}
</p>
</div>
))}
</div>
<div className='flex items-center gap-3 border-t border-white/10 bg-white/3 px-6 py-4 text-sm font-medium text-(--auth-brand-muted)'>
<Blocks aria-hidden='true' className='size-4' />
<span>{t('auth.marketingFooter')}</span>
</div>
</div>
))}
</div>
<div className='mt-12 flex items-center gap-2.5 border-t border-white/8 pt-6 text-xs text-(--auth-brand-muted)'>
<Blocks aria-hidden='true' className='size-4' />
<span>{t('auth.marketingFooter')}</span>
</div>
</div>
</div>
@@ -0,0 +1,37 @@
.auth-brand-panel {
background: var(--auth-brand-background);
background-image: radial-gradient(
ellipse at top right,
color-mix(in srgb, var(--auth-brand-highlight) 8%, transparent),
transparent 65%
);
}
/* Authentication surfaces have their own palette; application pages keep the selected theme. */
.dark .auth-page {
--background: #141923;
--foreground: #eef1f7;
--card: #1b2230;
--muted-foreground: #a4adbd;
--primary: #526bc7;
--primary-foreground: #ffffff;
--border: #30394b;
--input: #3b475c;
--ring: #8198ed;
--auth-brand-background: #192130;
--auth-brand-highlight: #a2b3ec;
--auth-brand-muted: #aab5c9;
}
.dark .auth-page > aside {
border-color: transparent;
}
.dark .auth-page [data-slot='input'],
.dark .auth-page [data-slot='input-group'] {
background-color: var(--card);
}
.dark .auth-page [data-slot='input-group'] input {
background-color: transparent;
}
@@ -5,6 +5,8 @@ import type { ReactElement, ReactNode } from 'react';
import { AuthSplitLayout } from '#extensions/nocobase-auth-split-layout/auth-split-layout';
import './auth-page.css';
export interface AuthPageProps {
readonly title: ReactNode;
readonly description: ReactNode;
@@ -26,6 +28,7 @@ export function AuthPage({
<AuthSplitLayout
aside={<BrandPanel />}
asideLabel={t('auth.about')}
className='auth-page'
description={description}
logo={
<>
@@ -68,42 +71,39 @@ function BrandPanel(): ReactElement {
];
return (
<div className='relative grid h-full place-items-center overflow-hidden bg-neutral-950 p-12 text-white'>
<div className='pointer-events-none absolute inset-0 [background-image:linear-gradient(currentColor_1px,transparent_1px),linear-gradient(90deg,currentColor_1px,transparent_1px)] [background-size:48px_48px] opacity-[0.08]' />
<div className='relative w-full max-w-xl'>
<p className='text-xs font-semibold tracking-[0.16em] text-white/55 uppercase'>
<div className='auth-brand-panel relative grid h-full place-items-center overflow-hidden p-12 text-white'>
<div className='relative w-full max-w-md'>
<p className='text-xs font-semibold tracking-[0.16em] text-(--auth-brand-highlight) uppercase'>
{t('auth.platform')}
</p>
<h2 className='mt-4 text-5xl leading-[1.05] font-semibold tracking-[-0.045em]'>
<h2 className='mt-6 text-4xl leading-tight font-semibold tracking-tight'>
{t('auth.marketingTitleFirst')}
<br />
{t('auth.marketingTitleSecond')}
<br />
{t('auth.marketingTitleThird')}
</h2>
<p className='mt-5 text-sm leading-6 text-white/60'>
<p className='mt-6 text-sm leading-7 text-(--auth-brand-muted)'>
{t('auth.marketingDescription')}
</p>
<div className='mt-8 overflow-hidden rounded-2xl bg-white text-neutral-950 shadow-2xl'>
<div className='space-y-5 p-6'>
{features.map(({ description, icon: Icon, id, title }) => (
<div className='flex gap-4' key={id}>
<span className='grid size-11 shrink-0 place-items-center rounded-xl bg-neutral-100 text-neutral-700'>
<Icon aria-hidden='true' className='size-5' />
</span>
<div>
<p className='font-semibold'>{title}</p>
<p className='mt-1 text-sm leading-6 text-neutral-500'>
{description}
</p>
</div>
<div className='mt-12 space-y-8'>
{features.map(({ description, icon: Icon, id, title }) => (
<div className='flex gap-4' key={id}>
<span className='grid size-10 shrink-0 place-items-center rounded-xl bg-(--auth-brand-highlight)/8 text-(--auth-brand-highlight)'>
<Icon aria-hidden='true' className='size-5' />
</span>
<div>
<p className='text-sm font-medium'>{title}</p>
<p className='mt-1.5 text-sm leading-6 text-(--auth-brand-muted)'>
{description}
</p>
</div>
))}
</div>
<div className='flex items-center gap-3 bg-neutral-100 px-6 py-4 text-sm font-medium text-neutral-600'>
<Blocks aria-hidden='true' className='size-4' />
<span>{t('auth.marketingFooter')}</span>
</div>
</div>
))}
</div>
<div className='mt-12 flex items-center gap-2.5 border-t border-white/8 pt-6 text-xs text-(--auth-brand-muted)'>
<Blocks aria-hidden='true' className='size-4' />
<span>{t('auth.marketingFooter')}</span>
</div>
</div>
</div>