diff --git a/services/platform/app/features/automations/hooks/use-automations.ts b/services/platform/app/features/automations/hooks/use-automations.ts index fe423962cf..fdd06d9978 100644 --- a/services/platform/app/features/automations/hooks/use-automations.ts +++ b/services/platform/app/features/automations/hooks/use-automations.ts @@ -83,6 +83,13 @@ export interface AutomationSummary { members?: string[]; /** role token -> composite agent slug (the manifest's cast). */ roles?: Record; + /** + * Subject contracts from the manifest (`subjects`) — how tasks this + * automation OWNS are operated from generic surfaces. Carried raw; consumers + * parse `subjects.task` through `taskSubjectContractSchema` (tolerant: + * an invalid contract reads as none). + */ + subjects?: { task?: unknown }; workflows: string[]; agents: string[]; /** @@ -125,6 +132,9 @@ export function useAutomations(organizationId: string): { ['automations', 'list', organizationId], api.automations.file_actions.listAutomations, { organizationId }, + // Hosts that resolve their org from a not-yet-loaded record (e.g. the + // task modal before the task query lands) pass '' — skip, don't 403. + { enabled: organizationId !== '' }, ); return { automations: (q.data as AutomationSummary[] | undefined) ?? [], diff --git a/services/platform/app/features/automations/registry/connected/folder-upload-card.tsx b/services/platform/app/features/automations/registry/connected/folder-upload-card.tsx index 33d9113e06..4819e2d398 100644 --- a/services/platform/app/features/automations/registry/connected/folder-upload-card.tsx +++ b/services/platform/app/features/automations/registry/connected/folder-upload-card.tsx @@ -13,6 +13,7 @@ import { IconButton } from '@tale/ui/icon-button'; import { Row, VStack } from '@tale/ui/layout'; import { SkeletonText } from '@tale/ui/skeleton'; import { Text } from '@tale/ui/text'; +import { Link } from '@tanstack/react-router'; import { ChevronRightIcon, FolderInput, Trash2 } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; @@ -22,25 +23,57 @@ import { useDocumentUpload, } from '@/app/features/documents/hooks/mutations'; import { useProjectDocuments } from '@/app/features/projects/hooks/queries'; +import { useConvexQuery } from '@/app/hooks/use-convex-query'; +import { api } from '@/convex/_generated/api'; import type { Id } from '@/convex/_generated/dataModel'; import { toId } from '@/convex/lib/type_cast_helpers'; import { useT } from '@/lib/i18n/client'; -import { useAutomationRuntime } from '../../runtime/automation-runtime'; +import { useAutomationRuntimeOptional } from '../../runtime/automation-runtime'; const MAX_LISTED = 8; export function FolderUploadCard({ folderId, orphaned = false, + organizationId: organizationIdProp, + projectId: projectIdProp, + showFolderName = false, }: { folderId: string; /** The bound folder was deleted — show a recover/remove notice instead of * the upload zone (uploading to a dead folder can only fail). */ orphaned?: boolean; + /** Outside an automation runtime (the task modal's folder-input surface) + * the host passes both ids explicitly; inside a runtime they come from + * context as before. */ + organizationId?: string; + projectId?: string; + /** Anchor the card to its folder: name in the title plus an "open in + * Knowledge" link. For hosts without ambient folder context (the task + * modal); a desk row IS its folder, so it stays off there. */ + showFolderName?: boolean; }) { const { t } = useT('automations'); - const { organizationId, projectId } = useAutomationRuntime(); + const runtime = useAutomationRuntimeOptional(); + const organizationId = organizationIdProp ?? runtime?.organizationId; + const projectId = projectIdProp ?? runtime?.projectId; + if (!organizationId) { + throw new Error( + 'FolderUploadCard needs an organizationId (prop or AutomationRuntimeProvider)', + ); + } + + // The folder anchor (title suffix + Knowledge link) — only fetched when the + // host asked for it; a desk row already IS its folder. + const folder = useConvexQuery( + api.folders.queries.getFolder, + showFolderName + ? // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the bound external ref is a folders id by the input contract + { folderId: folderId as Id<'folders'>, organizationId } + : 'skip', + ); + const folderName = showFolderName ? folder.data?.name : undefined; const inputRef = useRef(null); // null = follow the data (open while empty); a manual toggle pins it. const [openOverride, setOpenOverride] = useState(null); @@ -150,8 +183,10 @@ export function FolderUploadCard({ > - - {t('input.title')} {loaded ? `(${count})` : ''} + + {t('input.title')} + {folderName ? ` — ${folderName}` : ''}{' '} + {loaded ? `(${count})` : ''} + {showFolderName && folder.data && projectId !== undefined && ( + + + {t('input.openFolder')} + + )} )} diff --git a/services/platform/app/features/automations/runtime/automation-runtime.tsx b/services/platform/app/features/automations/runtime/automation-runtime.tsx index 096f52b427..aa72d9b7dd 100644 --- a/services/platform/app/features/automations/runtime/automation-runtime.tsx +++ b/services/platform/app/features/automations/runtime/automation-runtime.tsx @@ -75,3 +75,10 @@ export function useAutomationRuntime(): AutomationRuntime { } return value; } + +/** Null outside a provider — for components that also mount on generic + * surfaces (e.g. `FolderUploadCard` inside the task modal) and take their + * ids as props there. */ +export function useAutomationRuntimeOptional(): AutomationRuntime | null { + return useContext(AutomationRuntimeContext); +} diff --git a/services/platform/app/features/automations/runtime/resource-detail.test.tsx b/services/platform/app/features/automations/runtime/resource-detail.test.tsx index 4ef794d888..88190be3fe 100644 --- a/services/platform/app/features/automations/runtime/resource-detail.test.tsx +++ b/services/platform/app/features/automations/runtime/resource-detail.test.tsx @@ -325,6 +325,7 @@ describe('ResourceDetail — task subject composition', () => { expect(taskCommentsCalls[0]).toMatchObject({ composerHint: 'automations.detail.commentsDuringRun', + order: 'desc', }); }); diff --git a/services/platform/app/features/automations/runtime/resource-detail.tsx b/services/platform/app/features/automations/runtime/resource-detail.tsx index 742f62cc9e..e691eb5170 100644 --- a/services/platform/app/features/automations/runtime/resource-detail.tsx +++ b/services/platform/app/features/automations/runtime/resource-detail.tsx @@ -209,6 +209,7 @@ function TaskCommentsSection({ taskId }: { taskId: string }) { canComment={canComment} currentUserId={me?.userId} isAdmin={me?.isAdmin} + order="desc" composerHint={runActive ? t('detail.commentsDuringRun') : undefined} /> ); diff --git a/services/platform/app/features/operator/components/outcome-strip.tsx b/services/platform/app/features/operator/components/outcome-strip.tsx index 89b8c83ac4..e40d0bd834 100644 --- a/services/platform/app/features/operator/components/outcome-strip.tsx +++ b/services/platform/app/features/operator/components/outcome-strip.tsx @@ -26,7 +26,9 @@ import { parseDocumentArtifact } from '../lib/document-artifact'; import { asRecord, pickString } from '../lib/output-helpers'; import type { OperatorProjection, StepProjection } from '../types'; -function outcomeSteps(projection: OperatorProjection): StepProjection[] { +/** Steps annotated `ui.params.surface: "outcome"` — the shared outcome + * contract every host (desk card, task modal section) gates on. */ +export function outcomeSteps(projection: OperatorProjection): StepProjection[] { return projection.steps.filter( (s) => resolveSurface(s.params?.surface) === 'outcome', ); @@ -75,6 +77,14 @@ function slotShouldPulse(step: StepProjection, runInFlight: boolean): boolean { return runInFlight || ACTIVE_PENDING_STATES.has(step.partState); } +/** True while any outcome slot is still pending — lets a host show its + * "Not ready yet." hint without re-walking the rows. */ +export function outcomeHasPendingSlot(projection: OperatorProjection): boolean { + const runInFlight = + projection.status === 'running' || projection.status === 'pending'; + return outcomeSteps(projection).some((s) => isPendingSlot(s, runInFlight)); +} + type PreviewTarget = { documentId?: string; fileId?: string; @@ -121,7 +131,14 @@ function entriesForReadyStep(step: StepProjection): { const openClassName = 'text-primary focus-visible:ring-primary rounded-sm font-medium underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none'; -export function OutcomeStrip({ +/** + * Chrome-free outcome rows plus the shared preview dialog: ready artifacts + * (document → preview dialog, plain file → link, text → markdown), failed + * steps, and pending slots. Null when the pack annotates no outcome steps, + * so a host gates its own chrome on the same contract. Hosts: the desk's + * OutcomeStrip card, the task modal's Outcome section. + */ +export function OutcomeRows({ projection, }: { projection: OperatorProjection; @@ -129,7 +146,6 @@ export function OutcomeStrip({ const { t } = useT('operator'); const [preview, setPreview] = useState(null); const steps = outcomeSteps(projection); - // No pack annotation → omit entirely (not an empty card). if (steps.length === 0) return null; const runInFlight = @@ -221,6 +237,50 @@ export function OutcomeStrip({ const showSettledEmpty = !hasReadyOrError && !hasPendingSlot && rows.length === 0; + return ( + <> + {showSettledEmpty && ( + + {t('outcome.empty', { + defaultValue: + 'No results yet — they will appear here once a run produces them.', + })} + + )} + + {rows.length > 0 && ( +
    + {rows} +
+ )} + + { + if (!open) setPreview(null); + }} + documentId={preview?.documentId} + fileId={preview?.fileId} + fileName={preview?.fileName} + /> + + ); +} + +export function OutcomeStrip({ + projection, +}: { + projection: OperatorProjection; +}) { + const { t } = useT('operator'); + const steps = outcomeSteps(projection); + // No pack annotation → omit entirely (not an empty card). + if (steps.length === 0) return null; + const hasPendingSlot = outcomeHasPendingSlot(projection); + return ( // Same chrome as automation BlockFrame / Input — always expanded, never // nested under a "Workflow run" disclosure. @@ -248,34 +308,8 @@ export function OutcomeStrip({ )} - {showSettledEmpty && ( - - {t('outcome.empty', { - defaultValue: - 'No results yet — they will appear here once a run produces them.', - })} - - )} - - {rows.length > 0 && ( -
    - {rows} -
- )} +
- - { - if (!open) setPreview(null); - }} - documentId={preview?.documentId} - fileId={preview?.fileId} - fileName={preview?.fileName} - /> ); diff --git a/services/platform/app/features/tasks/components/kanban-board.test.tsx b/services/platform/app/features/tasks/components/kanban-board.test.tsx index c03e56409a..e1a54e788c 100644 --- a/services/platform/app/features/tasks/components/kanban-board.test.tsx +++ b/services/platform/app/features/tasks/components/kanban-board.test.tsx @@ -7,6 +7,12 @@ import { KanbanBoard } from './kanban-board'; type TaskRow = Doc<'tasks'>; +vi.mock('../hooks/use-task-subject-contract', () => ({ + useTaskSubjectContract: () => null, +})); +vi.mock('../hooks/use-task-status-choreography', () => ({ + useTaskStatusChoreography: () => async () => 'move' as const, +})); vi.mock('../hooks/mutations', () => ({ useMoveTask: () => ({ mutate: vi.fn(), isPending: false }), useAssignTask: () => ({ mutate: vi.fn(), isPending: false }), diff --git a/services/platform/app/features/tasks/components/status-picker.tsx b/services/platform/app/features/tasks/components/status-picker.tsx index 750a525cb5..313a04d4bb 100644 --- a/services/platform/app/features/tasks/components/status-picker.tsx +++ b/services/platform/app/features/tasks/components/status-picker.tsx @@ -24,11 +24,16 @@ export function StatusPicker({ onChange, align = 'start', disabled = false, + optionDescription, }: { status: TaskStatus; onChange: (status: TaskStatus) => void; align?: 'start' | 'center' | 'end'; disabled?: boolean; + /** Pre-flight hint under an option — what picking this status will DO + * beyond the plain write (e.g. "Starts the run."). Hosts + * derive it; the picker stays a dumb control. */ + optionDescription?: (status: TaskStatus) => string | undefined; }) { const { t } = useT('tasks'); const { t: tCommon } = useT('common'); @@ -40,6 +45,7 @@ export function StatusPicker({ const options: SearchableSelectOption[] = TASK_STATUS_ORDER.map((s) => ({ value: s, label: t(`status.${s}`), + description: optionDescription?.(s), })); const trigger = ( @@ -64,6 +70,7 @@ export function StatusPicker({ aria-label={t('fields.status')} searchPlaceholder={t('fields.status')} emptyText={tCommon('search.noResults')} + descriptionMode="inline" optionAction={(opt) => isTaskStatus(opt.value) ? : null } diff --git a/services/platform/app/features/tasks/components/task-automation-badge.test.tsx b/services/platform/app/features/tasks/components/task-automation-badge.test.tsx new file mode 100644 index 0000000000..7c5f1db117 --- /dev/null +++ b/services/platform/app/features/tasks/components/task-automation-badge.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TaskAutomationBadge } from './task-automation-badge'; + +const contractState: { current: unknown } = { current: null }; +vi.mock('../hooks/use-task-subject-contract', () => ({ + useTaskSubjectContract: () => contractState.current, +})); + +// Locale resolution is the automations feature's own concern — identity here. +vi.mock('@/app/features/automations/hooks/use-automation-text', () => ({ + useAutomationDisplay: + () => (automation: { name: string; description?: string }) => ({ + name: automation.name, + description: automation.description ?? '', + }), +})); + +const TASK = { + createdBy: 'vat-return-desk', + createdByType: 'app', +} as never; + +beforeEach(() => { + contractState.current = null; +}); + +describe('TaskAutomationBadge', () => { + it('renders nothing for unowned tasks', () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('marks owned tasks with the automation name and the choreography hint', () => { + contractState.current = { + automationSlug: 'vat-return-desk', + name: 'Swiss VAT return desk', + contract: { workflow: 'vat-return-desk' }, + }; + render(); + expect(screen.getByText('Swiss VAT return desk')).toBeInTheDocument(); + expect(screen.getByLabelText('automation.hint')).toBeInTheDocument(); + }); + + it('stays icon-only on the dense card by default', () => { + contractState.current = { + automationSlug: 'vat-return-desk', + name: 'Swiss VAT return desk', + contract: { workflow: 'vat-return-desk' }, + }; + render(); + expect(screen.queryByText('Swiss VAT return desk')).toBeNull(); + expect(screen.getByLabelText('automation.hint')).toBeInTheDocument(); + }); +}); diff --git a/services/platform/app/features/tasks/components/task-automation-badge.tsx b/services/platform/app/features/tasks/components/task-automation-badge.tsx new file mode 100644 index 0000000000..8e75010b28 --- /dev/null +++ b/services/platform/app/features/tasks/components/task-automation-badge.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { Workflow } from 'lucide-react'; + +import { Tooltip } from '@/app/components/ui/overlays/tooltip'; +import { useAutomationDisplay } from '@/app/features/automations/hooks/use-automation-text'; +import { useT } from '@/lib/i18n/client'; +import { cn } from '@/lib/utils/cn'; + +import { + useTaskSubjectContract, + type TaskOwnershipFields, +} from '../hooks/use-task-subject-contract'; + +/** + * The ownership marker for an automation-owned task — the one visible signal + * that this card does NOT behave like a plain task (its status verbs run the + * owning workflow). Icon-only on the dense board card, icon + automation + * name in the modal; both carry the explanatory tooltip. Everything shown is + * resolved from the owning manifest (display name via its i18n), never + * product literals. Renders nothing for unowned tasks. + */ +export function TaskAutomationBadge({ + organizationId, + task, + showName = false, + className, +}: { + organizationId: string; + task: TaskOwnershipFields; + /** Icon + automation name (the modal); default icon-only (board card). */ + showName?: boolean; + className?: string; +}) { + const { t } = useT('tasks'); + const automationDisplay = useAutomationDisplay(); + const resolved = useTaskSubjectContract(organizationId, task); + if (!resolved) return null; + + const name = automationDisplay(resolved).name; + const hint = t('automation.hint', { name }); + return ( + + + + {showName && {name}} + + + ); +} diff --git a/services/platform/app/features/tasks/components/task-card.tsx b/services/platform/app/features/tasks/components/task-card.tsx index c3185c27c1..6694551238 100644 --- a/services/platform/app/features/tasks/components/task-card.tsx +++ b/services/platform/app/features/tasks/components/task-card.tsx @@ -15,6 +15,7 @@ import { useAssignTask, useUpdateTask } from '../hooks/mutations'; import { subtaskProgress } from '../lib/subtasks'; import { AssigneePicker } from './assignee-picker'; import { PriorityPicker } from './priority-picker'; +import { TaskAutomationBadge } from './task-automation-badge'; import { useTaskBoardContext } from './task-board-context'; import { AgentWorkingIndicator, @@ -162,6 +163,10 @@ export function TaskCard({ )} + diff --git a/services/platform/app/features/tasks/components/task-modal.tsx b/services/platform/app/features/tasks/components/task-modal.tsx index 38d1e76f35..6fe73e14b3 100644 --- a/services/platform/app/features/tasks/components/task-modal.tsx +++ b/services/platform/app/features/tasks/components/task-modal.tsx @@ -1,6 +1,7 @@ 'use client'; import { Button } from '@tale/ui/button'; +import { useLocale } from '@tale/ui/i18n/locale-provider'; import { Row, Stack } from '@tale/ui/layout'; import { ResponsiveDialog, @@ -16,18 +17,23 @@ import { useEffect, useState, type ReactNode } from 'react'; import { DatePicker } from '@/app/components/ui/forms/date-picker'; import { Input } from '@/app/components/ui/forms/input'; import { Textarea } from '@/app/components/ui/forms/textarea'; +import { useAutomationDisplay } from '@/app/features/automations/hooks/use-automation-text'; import { type FileAttachment, useConvexFileUpload, } from '@/app/features/chat/hooks/use-convex-file-upload'; import { useProject } from '@/app/features/projects/hooks/queries'; +import { useConvexAction } from '@/app/hooks/use-convex-action'; +import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { useCurrentMemberContext } from '@/app/hooks/use-current-member-context'; import { useFormatDate } from '@/app/hooks/use-format-date'; import { toast } from '@/app/hooks/use-toast'; +import { api } from '@/convex/_generated/api'; import type { Id } from '@/convex/_generated/dataModel'; import { TASK_TITLE_MAX } from '@/convex/tasks/helpers'; import { useT } from '@/lib/i18n/client'; import { TASK_UPLOAD_ALLOWED_TYPES } from '@/lib/shared/file-types'; +import { isActiveExecutionStatus } from '@/lib/shared/platform/run_capacity'; import { formatTaskIdentifier } from '@/lib/shared/project_key'; import { cn } from '@/lib/utils/cn'; @@ -39,6 +45,14 @@ import { } from '../hooks/mutations'; import { useSubtasks, useTask } from '../hooks/queries'; import { useActorDirectory } from '../hooks/use-actor-directory'; +import { + plannedTransitionKind, + useTaskStatusChoreography, +} from '../hooks/use-task-status-choreography'; +import { + useTaskSubjectContract, + useTaskSubjectTemplates, +} from '../hooks/use-task-subject-contract'; import { type TaskActorType, type TaskPriority, @@ -56,14 +70,29 @@ import { StatusPicker } from './status-picker'; import { TaskArchiveDialog } from './task-archive-dialog'; import { TaskArchivedBadge } from './task-archived-badge'; import { TaskAttachments } from './task-attachments'; +import { TaskAutomationBadge } from './task-automation-badge'; import { TaskComments } from './task-comments'; import { TaskDependencies } from './task-dependencies'; import { SubtaskProgress } from './task-indicators'; +import { TaskOutcomeSection } from './task-outcome'; import { TaskReviewCard } from './task-review-card'; import { TaskRunFailureBanner } from './task-run-failure-banner'; import { TaskStatusBadge } from './task-status-badge'; +import { TaskSubjectFiles } from './task-subject-files'; import { TaskTimeline } from './task-timeline'; +/** Anchored test against a contract-declared pattern. The pattern is + * operator-authored org config — a malformed one must not crash the dialog, + * so construction failures read as "matches" (server re-validates anyway). */ +function safeMatches(pattern: string, value: string): boolean { + try { + return new RegExp(pattern).test(value); + } catch (error) { + console.warn('[tasks] invalid subject naming pattern', pattern, error); + return true; + } +} + /** Strip the client-only `previewUrl` so the value matches the mutations' * strict `attachments` validator. Always an array (an empty array sent to * `updateTask` CLEARS the field — `undefined` would mean "leave untouched"). */ @@ -258,7 +287,80 @@ function CreateTaskBody({ const [labels, setLabels] = useState([]); const [submitting, setSubmitting] = useState(false); + // Template creation — the `subjects.task.create` contracts of installed + // automations (e.g. the VAT desk's "new quarter"). Selecting one swaps the + // free-form fields for the contract's single input; the server call is the + // SAME upsert seam the desk's own create button uses, so a board-created + // and a desk-created quarter converge on one task (never a zombie copy). + const templates = useTaskSubjectTemplates(organizationId); + const automationDisplay = useAutomationDisplay(); + const { locale } = useLocale(); + const [templateSlug, setTemplateSlug] = useState(null); + const [templateName, setTemplateName] = useState(''); + const createFromTemplate = useConvexAction( + api.tasks.public_actions.createTaskFromExternalIssue, + ); + const activeTemplate = + templates.find((entry) => entry.automationSlug === templateSlug) ?? null; + const templateField = activeTemplate?.contract.create?.field; + const templateFieldText = { + ...templateField, + ...templateField?.i18n?.[locale], + }; + const templateNaming = activeTemplate?.contract.input?.naming; + const templateNameOk = + templateNaming === undefined || + templateName.trim() === '' || + safeMatches(templateNaming, templateName.trim()); + + const submitTemplate = async (entry: NonNullable) => { + const name = templateName.trim(); + if (!name || !templateNameOk || submitting) return; + setSubmitting(true); + try { + const { contract } = entry; + const result = await createFromTemplate.mutateAsync({ + organizationId, + projectId, + externalSystem: contract.externalSystem ?? entry.automationSlug, + ensureFolder: { + name, + setupFolderName: contract.input?.setupFolderName, + }, + title: (contract.create?.titleTemplate ?? '{name}').replace( + '{name}', + name, + ), + description: contract.create?.description, + automationSlug: entry.automationSlug, + }); + toast({ + title: result.created + ? t('create.templateCreated') + : t('create.templateExists'), + variant: result.created ? 'success' : undefined, + }); + onClose(); + } catch (error) { + console.error('Create task from template error:', error); + const message = + error instanceof ConvexError && typeof error.data?.message === 'string' + ? error.data.message + : undefined; + toast({ + title: tCommon('errors.generic'), + description: message, + variant: 'destructive', + }); + setSubmitting(false); + } + }; + const submit = async () => { + if (activeTemplate) { + await submitTemplate(activeTemplate); + return; + } const trimmed = title.trim(); if (!trimmed || submitting) return; setSubmitting(true); @@ -296,94 +398,164 @@ function CreateTaskBody({ } main={ <> - setTitle(e.target.value)} - disabled={submitting} - autoFocus - required - // Hard-cap at the server limit (validateTitle rejects > TASK_TITLE_MAX) - // so an over-long title can't reach the mutation and strand the dialog - // behind a generic error toast. - maxLength={TASK_TITLE_MAX} - onKeyDown={(e) => { - // Cmd/Ctrl+Enter submits from the title (fast path). - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault(); - void submit(); - } - }} - /> - - - void uploadFiles(files)} - onRemove={removeAttachment} - /> + {templates.length > 0 && ( +
+ + {t('create.type')} + + + + {templates.map((entry) => ( + + ))} + +
+ )} + {activeTemplate ? ( + <> + setTemplateName(e.target.value)} + disabled={submitting} + autoFocus + required + aria-describedby="task-template-help" + aria-invalid={!templateNameOk} + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + void submit(); + } + }} + /> + + {!templateNameOk + ? t('create.invalidName') + : (templateFieldText.help ?? '')} + + + ) : ( + <> + setTitle(e.target.value)} + disabled={submitting} + autoFocus + required + // Hard-cap at the server limit (validateTitle rejects > TASK_TITLE_MAX) + // so an over-long title can't reach the mutation and strand the dialog + // behind a generic error toast. + maxLength={TASK_TITLE_MAX} + onKeyDown={(e) => { + // Cmd/Ctrl+Enter submits from the title (fast path). + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + void submit(); + } + }} + /> + + + void uploadFiles(files)} + onRemove={removeAttachment} + /> + + )} } panel={ - <> - - - - - - - - setAssignee({ type, id })} - onUnassign={() => setAssignee(null)} - /> - - - setDueDate(ms ?? undefined)} - /> - - - - - - + activeTemplate ? ( + // The contract fixes the created task's shape (status, ownership, + // title) — the free-form pickers would be dead controls here. + <> + ) : ( + <> + + + + + + + + setAssignee({ type, id })} + onUnassign={() => setAssignee(null)} + /> + + + setDueDate(ms ?? undefined)} + /> + + + + + + + ) } footer={ @@ -392,7 +564,12 @@ function CreateTaskBody({ @@ -429,6 +606,41 @@ function EditTaskBody({ const updateTask = useUpdateTask(); const updateStatus = useUpdateTaskStatus(); + const choreograph = useTaskStatusChoreography(task?.organizationId ?? ''); + // Pre-flight hints for the status picker: what a status verb will DO on an + // automation-owned task (start / rerun with feedback / cancel), from the + // same matrix that executes it — the hint can never drift from behavior. + const subjectContract = useTaskSubjectContract( + task?.organizationId ?? '', + task, + ); + const automationDisplay = useAutomationDisplay(); + const { data: latestRun } = useConvexQuery( + api.workflow_executions.queries.getLatestExecutionForSubject, + task && subjectContract + ? { + organizationId: task.organizationId, + subjectType: 'task', + subjectId: task._id, + } + : 'skip', + ); + const statusOptionDescription = (option: TaskStatus): string | undefined => { + if (!task || !subjectContract) return undefined; + const runActive = + latestRun != null && isActiveExecutionStatus(latestRun.status); + const kind = plannedTransitionKind( + subjectContract.contract, + task.status, + option, + runActive, + ); + if (kind === null) return undefined; + const name = automationDisplay(subjectContract).name; + if (kind === 'start') return t('run.hintStart', { name }); + if (kind === 'request_changes') return t('run.hintRequestChanges'); + return t('run.hintCancel'); + }; const assignTask = useAssignTask(); const createTask = useCreateTask(); const { uploadingFiles, uploadFiles, clearAttachments } = useConvexFileUpload( @@ -515,15 +727,26 @@ function EditTaskBody({ - {identifier && ( - - {identifier} - - )} + {/* Breadcrumb row: identifier left, the owning automation's badge + right — the visible "this task is operated" signal. */} + + {identifier ? ( + + {identifier} + + ) : ( + {isArchived && } {task.title} @@ -588,11 +811,12 @@ function EditTaskBody({ )} - @@ -689,6 +913,11 @@ function EditTaskBody({ )} + + - void updateStatus - .mutateAsync({ taskId: task._id, status }) + // An automation-owned task interprets the status verb through + // its workflow (In progress = start the run, leaving it = + // cancel); a plain task writes the status directly. + void choreograph(task, status) + .then((outcome) => { + if (outcome === 'move') { + return updateStatus.mutateAsync({ + taskId: task._id, + status, + }); + } + return undefined; + }) .catch(onMutationError) } /> diff --git a/services/platform/app/features/tasks/components/task-outcome.test.tsx b/services/platform/app/features/tasks/components/task-outcome.test.tsx new file mode 100644 index 0000000000..6c6693fbc3 --- /dev/null +++ b/services/platform/app/features/tasks/components/task-outcome.test.tsx @@ -0,0 +1,131 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + OperatorProjection, + StepProjection, +} from '@/app/features/operator/types'; + +import { TaskOutcomeSection } from './task-outcome'; + +// The two data seams — the latest-run query and the projection hook — are +// stubbed; OutcomeRows itself stays real so the shared outcome contract +// (surface: "outcome" steps → entries) is exercised, not reimplemented. +const latestRun: { + current: { executionId: string; status: string; startedAt: number } | null; +} = { current: null }; +vi.mock('@/app/hooks/use-convex-query', () => ({ + useConvexQuery: () => ({ data: latestRun.current, isLoading: false }), +})); + +const projectionState: { current: OperatorProjection | null } = { + current: null, +}; +vi.mock('@/app/features/operator/hooks/use-execution-projection', () => ({ + useExecutionProjection: () => ({ + projection: projectionState.current, + isLoading: false, + error: null, + }), +})); + +// Portal/markdown internals are irrelevant to the section contract. +vi.mock('@/app/features/documents/components/document-preview-dialog', () => ({ + DocumentPreviewDialog: () => null, +})); +vi.mock( + '@/app/features/chat/components/message-bubble/markdown-renderer', + () => ({ + MarkdownContent: ({ content }: { content: string }) =>
{content}
, + }), +); + +function outcomeStep(over: Partial): StepProjection { + return { + stepSlug: 'deliver', + name: 'Deliver', + stepType: 'action', + render: 'status', + partState: 'output_available', + params: { surface: 'outcome' }, + ...over, + } as StepProjection; +} + +function projectionWith( + steps: StepProjection[], + status = 'completed', +): OperatorProjection { + return { status, startedAt: 1, stages: [], steps }; +} + +function renderSection() { + return render( + , + ); +} + +beforeEach(() => { + latestRun.current = null; + projectionState.current = null; +}); + +describe('TaskOutcomeSection', () => { + it('renders nothing for a task without a run', () => { + const { container } = renderSection(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when the run has no outcome-annotated steps', () => { + latestRun.current = { + executionId: 'exec1', + status: 'completed', + startedAt: 1, + }; + projectionState.current = projectionWith([outcomeStep({ params: {} })]); + const { container } = renderSection(); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows the heading and file entries for a run with outcome files', () => { + latestRun.current = { + executionId: 'exec1', + status: 'completed', + startedAt: 1, + }; + projectionState.current = projectionWith([ + outcomeStep({ + files: [{ name: 'screenshot.png', url: 'https://files/shot' }], + }), + ]); + renderSection(); + expect( + screen.getByRole('heading', { name: 'detail.outcome' }), + ).toBeInTheDocument(); + const link = screen.getByRole('link', { name: 'screenshot.png' }); + expect(link).toHaveAttribute('href', 'https://files/shot'); + }); + + it('shows pending slots while the run is still executing', () => { + latestRun.current = { + executionId: 'exec1', + status: 'running', + startedAt: 1, + }; + projectionState.current = projectionWith( + [ + outcomeStep({ + partState: 'upcoming', + promisedTitle: 'return.xml', + }), + ], + 'running', + ); + renderSection(); + expect(screen.getByText('return.xml')).toBeInTheDocument(); + }); +}); diff --git a/services/platform/app/features/tasks/components/task-outcome.tsx b/services/platform/app/features/tasks/components/task-outcome.tsx new file mode 100644 index 0000000000..5224c302ff --- /dev/null +++ b/services/platform/app/features/tasks/components/task-outcome.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { Stack } from '@tale/ui/layout'; +import { Text } from '@tale/ui/text'; + +import { + OutcomeRows, + outcomeSteps, +} from '@/app/features/operator/components/outcome-strip'; +import { useExecutionProjection } from '@/app/features/operator/hooks/use-execution-projection'; +import { useConvexQuery } from '@/app/hooks/use-convex-query'; +import { api } from '@/convex/_generated/api'; +import type { Id } from '@/convex/_generated/dataModel'; +import { useT } from '@/lib/i18n/client'; + +/** + * The task detail's Outcome section: the latest subject run's deliverables, + * on the SAME contract as the desk row's OutcomeStrip — steps annotated + * `ui.params.surface: "outcome"`, whatever their artifact kind (documents + * open in the preview dialog, plain files such as screenshots link out, + * text renders as markdown; pending slots pulse while the run executes). + * Renders nothing for tasks without a run, or whose pack declares no + * outcome steps — ordinary board tasks are untouched. + */ +export function TaskOutcomeSection({ + organizationId, + taskId, +}: { + organizationId: string; + taskId: Id<'tasks'>; +}) { + const { data: run } = useConvexQuery( + api.workflow_executions.queries.getLatestExecutionForSubject, + { organizationId, subjectType: 'task', subjectId: taskId }, + ); + if (!run) return null; + return ( + + ); +} + +/** Split so the projection hook only mounts once a run exists. */ +function TaskOutcomeBody({ + organizationId, + executionId, +}: { + organizationId: string; + executionId: string; +}) { + const { t } = useT('tasks'); + const { projection } = useExecutionProjection({ + organizationId, + executionId, + }); + if (!projection || outcomeSteps(projection).length === 0) return null; + return ( + + + {t('detail.outcome')} + + + + ); +} diff --git a/services/platform/app/features/tasks/components/task-subject-files.test.tsx b/services/platform/app/features/tasks/components/task-subject-files.test.tsx new file mode 100644 index 0000000000..221b0093d2 --- /dev/null +++ b/services/platform/app/features/tasks/components/task-subject-files.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TaskSubjectFiles } from './task-subject-files'; + +const contractState: { current: unknown } = { current: null }; +vi.mock('../hooks/use-task-subject-contract', () => ({ + useTaskSubjectContract: () => contractState.current, +})); + +// Both leaf surfaces stubbed — this suite asserts the SWAP rule, not the +// folder card or the attachments zone themselves. +vi.mock( + '@/app/features/automations/registry/connected/folder-upload-card', + () => ({ + FolderUploadCard: (props: { folderId: string }) => ( +
{props.folderId}
+ ), + }), +); +vi.mock('./task-attachments', () => ({ + TaskAttachments: () =>
, +})); + +const FOLDER_CONTRACT = { + automationSlug: 'vat-return-desk', + contract: { workflow: 'vat-return-desk', input: { kind: 'folder' } }, +}; + +function renderSurface(over: Record = {}) { + return render( + {}} + onRemove={() => {}} + />, + ); +} + +beforeEach(() => { + contractState.current = null; +}); + +describe('TaskSubjectFiles', () => { + it('keeps the plain attachments zone for unowned tasks', () => { + renderSurface(); + expect(screen.getByTestId('task-attachments')).toBeInTheDocument(); + expect(screen.queryByTestId('folder-card')).toBeNull(); + }); + + it('swaps in the bound folder card for folder-input tasks', () => { + contractState.current = FOLDER_CONTRACT; + renderSurface(); + expect(screen.getByTestId('folder-card')).toHaveTextContent('folder_1'); + expect(screen.queryByTestId('task-attachments')).toBeNull(); + }); + + it('keeps the attachments zone when the binding is missing', () => { + contractState.current = FOLDER_CONTRACT; + renderSurface({ externalId: undefined }); + expect(screen.getByTestId('task-attachments')).toBeInTheDocument(); + }); +}); diff --git a/services/platform/app/features/tasks/components/task-subject-files.tsx b/services/platform/app/features/tasks/components/task-subject-files.tsx new file mode 100644 index 0000000000..be44e04c26 --- /dev/null +++ b/services/platform/app/features/tasks/components/task-subject-files.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { FolderUploadCard } from '@/app/features/automations/registry/connected/folder-upload-card'; +import type { FileAttachment } from '@/app/features/chat/hooks/use-convex-file-upload'; +import type { Id } from '@/convex/_generated/dataModel'; + +import { useTaskSubjectContract } from '../hooks/use-task-subject-contract'; +import { TaskAttachments } from './task-attachments'; + +/** + * The task modal's input surface. For a task whose owning automation declares + * a FOLDER input (`subjects.task.input.kind: 'folder'`), the Attachments zone + * is replaced by the bound folder's upload card — files land where the run + * actually reads them, not as task-blob copies the pipeline never sees. Any + * pre-existing task attachments stay listed below (read path unchanged). + * Tasks without a folder-bound owner keep the plain Attachments zone. + */ +export function TaskSubjectFiles({ + organizationId, + task, + attachments, + uploadingFiles, + canEdit, + onUpload, + onRemove, +}: { + organizationId: string; + task: { + projectId: Id<'projects'>; + createdBy: string; + createdByType: 'user' | 'agent' | 'app'; + externalSystem?: string; + externalId?: string; + }; + attachments: FileAttachment[]; + uploadingFiles: string[]; + canEdit: boolean; + onUpload: (files: File[]) => void; + onRemove: (fileId: string) => void; +}) { + const resolved = useTaskSubjectContract(organizationId, task); + const folderId = + resolved?.contract.input?.kind === 'folder' && + typeof task.externalId === 'string' && + task.externalId !== '' + ? task.externalId + : null; + + if (folderId === null) { + return ( + + ); + } + + return ( + <> + + {attachments.length > 0 && ( + + )} + + ); +} diff --git a/services/platform/app/features/tasks/components/tasks-list.test.tsx b/services/platform/app/features/tasks/components/tasks-list.test.tsx index 36fcc86a98..6619591a90 100644 --- a/services/platform/app/features/tasks/components/tasks-list.test.tsx +++ b/services/platform/app/features/tasks/components/tasks-list.test.tsx @@ -7,6 +7,9 @@ import { TasksList } from './tasks-list'; type TaskRow = Doc<'tasks'>; +vi.mock('../hooks/use-task-status-choreography', () => ({ + useTaskStatusChoreography: () => async () => 'move' as const, +})); vi.mock('../hooks/mutations', () => ({ useMoveTask: () => ({ mutate: vi.fn(), isPending: false }), useAssignTask: () => ({ mutate: vi.fn(), isPending: false }), diff --git a/services/platform/app/features/tasks/hooks/use-task-board-dnd.ts b/services/platform/app/features/tasks/hooks/use-task-board-dnd.ts index 270ea8e05f..16905a4bfd 100644 --- a/services/platform/app/features/tasks/hooks/use-task-board-dnd.ts +++ b/services/platform/app/features/tasks/hooks/use-task-board-dnd.ts @@ -15,6 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { TaskRow } from '../components/task-card'; import { TASK_STATUS_ORDER, type TaskStatus } from '../lib/display'; import { useMoveTask } from './mutations'; +import { useTaskStatusChoreography } from './use-task-status-choreography'; export type TaskColumns = Record; @@ -71,6 +72,10 @@ export interface TaskBoardDnd { */ export function useTaskBoardDnd(tasks: TaskRow[]): TaskBoardDnd { const moveTask = useMoveTask(); + // Cross-column drags on automation-owned tasks route through the owning + // workflow's choreography (drag to In progress = start, drag out = cancel) + // instead of a bare status write. Rows all belong to one org. + const choreograph = useTaskStatusChoreography(tasks[0]?.organizationId ?? ''); const [activeId, setActiveId] = useState(null); const sensors = useSensors( @@ -211,16 +216,29 @@ export function useTaskBoardDnd(tasks: TaskRow[]): TaskBoardDnd { } // Resolve back to typed task ids via the row map (no unsafe casts). - const taskId = byId.get(activeIdStr)?._id; - if (!taskId) return; - moveTask.mutate({ - taskId, - status: container, - beforeTaskId: beforeIdStr ? byId.get(beforeIdStr)?._id : undefined, - afterTaskId: afterIdStr ? byId.get(afterIdStr)?._id : undefined, + const row = byId.get(activeIdStr); + if (!row) return; + const move = () => + moveTask.mutate({ + taskId: row._id, + status: container, + beforeTaskId: beforeIdStr ? byId.get(beforeIdStr)?._id : undefined, + afterTaskId: afterIdStr ? byId.get(afterIdStr)?._id : undefined, + }); + if (origContainer === container) { + move(); + return; + } + // Cross-column: let the owning automation's choreography interpret the + // board verb first. 'move' → the plain write still lands the drop; + // 'handled' → the workflow drives the status (keep the optimistic + // placement); 'blocked' → snap the card back where it came from. + void choreograph(row, container).then((outcome) => { + if (outcome === 'move') move(); + else if (outcome === 'blocked') setColumns(columnsFromProps); }); }, - [byId, columnsFromProps, moveTask, setColumns], + [byId, choreograph, columnsFromProps, moveTask, setColumns], ); const onDragCancel = useCallback(() => { diff --git a/services/platform/app/features/tasks/hooks/use-task-status-choreography.test.ts b/services/platform/app/features/tasks/hooks/use-task-status-choreography.test.ts new file mode 100644 index 0000000000..6deef47ca1 --- /dev/null +++ b/services/platform/app/features/tasks/hooks/use-task-status-choreography.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; + +import type { TaskSubjectContract } from '@/lib/shared/schemas/automations'; + +import { + decideTaskStatusTransition, + plannedTransitionKind, +} from './use-task-status-choreography'; + +const CONTRACT: TaskSubjectContract = { + workflow: 'vat-return-desk', + externalSystem: 'vatplus', + input: { kind: 'folder' }, + start: { + when: 'hasFiles && status == backlog || hasFiles && status == todo || hasFiles && status == cancelled', + }, + review: { requestChanges: true }, +}; + +function decide(over: { + contract?: TaskSubjectContract | null; + from: string; + to: string; + runActive?: boolean; + hasFiles?: boolean; +}) { + return decideTaskStatusTransition({ + contract: over.contract === undefined ? CONTRACT : over.contract, + from: over.from, + to: over.to, + runActive: over.runActive ?? false, + hasFiles: over.hasFiles ?? false, + }); +} + +describe('decideTaskStatusTransition', () => { + it('plain move for unowned tasks', () => { + expect(decide({ contract: null, from: 'todo', to: 'in_progress' })).toEqual( + { kind: 'move' }, + ); + }); + + it('drag to In progress = start when the gate holds', () => { + expect(decide({ from: 'todo', to: 'in_progress', hasFiles: true })).toEqual( + { kind: 'start' }, + ); + expect( + decide({ from: 'cancelled', to: 'in_progress', hasFiles: true }), + ).toEqual({ kind: 'start' }); + }); + + it('blocks with feedback when ONLY the input is missing', () => { + expect( + decide({ from: 'todo', to: 'in_progress', hasFiles: false }), + ).toEqual({ kind: 'block', reason: 'missing_input' }); + }); + + it('plain move when the gate fails on status (reopening done)', () => { + expect(decide({ from: 'done', to: 'in_progress', hasFiles: true })).toEqual( + { kind: 'move' }, + ); + }); + + it('In review back to In progress = request changes', () => { + expect(decide({ from: 'in_review', to: 'in_progress' })).toEqual({ + kind: 'request_changes', + }); + }); + + it('request changes falls back to a plain move without the review verb', () => { + expect( + decide({ + contract: { ...CONTRACT, review: undefined }, + from: 'in_review', + to: 'in_progress', + }), + ).toEqual({ kind: 'move' }); + }); + + it('dragging an actively running task out cancels first', () => { + expect( + decide({ from: 'in_progress', to: 'cancelled', runActive: true }), + ).toEqual({ kind: 'cancel', alsoMove: false }); + expect( + decide({ from: 'in_progress', to: 'todo', runActive: true }), + ).toEqual({ kind: 'cancel', alsoMove: true }); + }); + + it('leaving a settled In progress is a plain move', () => { + expect( + decide({ from: 'in_progress', to: 'in_review', runActive: false }), + ).toEqual({ kind: 'move' }); + }); + + it('non-in_progress targets on owned tasks stay plain moves', () => { + expect(decide({ from: 'backlog', to: 'todo', hasFiles: true })).toEqual({ + kind: 'move', + }); + expect(decide({ from: 'in_review', to: 'done' })).toEqual({ + kind: 'move', + }); + }); + + it('contracts without start gating never auto-start', () => { + expect( + decide({ + contract: { ...CONTRACT, start: undefined }, + from: 'todo', + to: 'in_progress', + hasFiles: true, + }), + ).toEqual({ kind: 'move' }); + }); +}); + +describe('plannedTransitionKind (status-picker pre-flight hints)', () => { + it('names the intent from the SAME matrix that executes it', () => { + expect(plannedTransitionKind(CONTRACT, 'todo', 'in_progress', false)).toBe( + 'start', + ); + expect( + plannedTransitionKind(CONTRACT, 'in_review', 'in_progress', false), + ).toBe('request_changes'); + expect(plannedTransitionKind(CONTRACT, 'in_progress', 'todo', true)).toBe( + 'cancel', + ); + }); + + it('plain moves carry no hint', () => { + expect( + plannedTransitionKind(CONTRACT, 'backlog', 'todo', false), + ).toBeNull(); + expect( + plannedTransitionKind(CONTRACT, 'done', 'in_progress', false), + ).toBeNull(); + expect( + plannedTransitionKind(CONTRACT, 'in_progress', 'todo', false), + ).toBeNull(); + }); +}); diff --git a/services/platform/app/features/tasks/hooks/use-task-status-choreography.ts b/services/platform/app/features/tasks/hooks/use-task-status-choreography.ts new file mode 100644 index 0000000000..02b3c18e02 --- /dev/null +++ b/services/platform/app/features/tasks/hooks/use-task-status-choreography.ts @@ -0,0 +1,222 @@ +'use client'; + +import { useCallback } from 'react'; + +import { useAutomations } from '@/app/features/automations/hooks/use-automations'; +import { useConvexAction } from '@/app/hooks/use-convex-action'; +import { useConvexClient } from '@/app/hooks/use-convex-client'; +import { toast } from '@/app/hooks/use-toast'; +import { api } from '@/convex/_generated/api'; +import type { Id } from '@/convex/_generated/dataModel'; +import { useT } from '@/lib/i18n/client'; +import { isActiveExecutionStatus } from '@/lib/shared/platform/run_capacity'; +import { evaluateWhen } from '@/lib/shared/platform/when_predicate'; +import type { TaskSubjectContract } from '@/lib/shared/schemas/automations'; + +import { resolveTaskSubjectContract } from './use-task-subject-contract'; + +/** The task fields status choreography reads. */ +export interface ChoreographedTask { + _id: Id<'tasks'>; + projectId: Id<'projects'>; + status: string; + createdBy: string; + createdByType: 'user' | 'agent' | 'app'; + externalSystem?: string; + externalId?: string; +} + +export type TaskTransitionPlan = + | { kind: 'move' } + | { kind: 'start' } + | { kind: 'request_changes' } + | { kind: 'cancel'; alsoMove: boolean } + | { kind: 'block'; reason: 'missing_input' }; + +/** + * Map a status change on an automation-owned task onto the owning workflow's + * choreography — the board's NATIVE verbs stay the interface (drag to + * In progress = start the run; drag it out = cancel; In review back to + * In progress = request changes). Pure, so the whole matrix is unit-testable: + * + * - no contract / no-op move → plain move + * - leaving in_progress w/ active run → cancel (+ plain move unless the + * target IS cancelled — cancel parks + * there itself) + * - in_review → in_progress → request changes (the run re-reads + * the timeline feedback) + * - elsewhere → in_progress → start when the contract's + * `start.when` holds; if it fails + * ONLY for missing input, block with + * feedback; otherwise plain move + * (e.g. reopening done → in_progress + * outside the start set) + */ +export function decideTaskStatusTransition(args: { + contract: TaskSubjectContract | null; + from: string; + to: string; + runActive: boolean; + hasFiles: boolean; +}): TaskTransitionPlan { + const { contract, from, to } = args; + if (!contract || from === to) return { kind: 'move' }; + + if (from === 'in_progress' && args.runActive) { + return { kind: 'cancel', alsoMove: to !== 'cancelled' }; + } + + if (to !== 'in_progress') return { kind: 'move' }; + + if (from === 'in_review') { + return contract.review?.requestChanges === true + ? { kind: 'request_changes' } + : { kind: 'move' }; + } + + const when = contract.start?.when; + if (when === undefined) return { kind: 'move' }; + if (evaluateWhen(when, { status: from, hasFiles: args.hasFiles })) { + return { kind: 'start' }; + } + // Would the gate pass with input present? Then input is the ONLY blocker — + // surface that instead of silently moving a card the run will never pick up. + if (evaluateWhen(when, { status: from, hasFiles: true })) { + return { kind: 'block', reason: 'missing_input' }; + } + return { kind: 'move' }; +} + +/** + * The INTENT a status option would carry, for pre-flight hints in the status + * picker — derived from the SAME matrix that executes the transition (with + * `hasFiles` assumed present: the hint names the intent; the gate itself + * still blocks at action time). `null` = a plain move, no hint. + */ +export function plannedTransitionKind( + contract: TaskSubjectContract, + from: string, + to: string, + runActive: boolean, +): 'start' | 'request_changes' | 'cancel' | null { + const plan = decideTaskStatusTransition({ + contract, + from, + to, + runActive, + hasFiles: true, + }); + return plan.kind === 'start' || + plan.kind === 'request_changes' || + plan.kind === 'cancel' + ? plan.kind + : null; +} + +export type TaskTransitionOutcome = 'handled' | 'blocked' | 'move'; + +/** + * The executor: resolve the owning contract, gather the live facts the plan + * needs (active subject run; bound-folder files), run the plan's action, and + * tell the caller whether it still owes the plain status write (`'move'`), + * should revert its optimistic UI (`'blocked'`), or is done (`'handled'` — + * the workflow's own ack step drives the status from here). + */ +export function useTaskStatusChoreography(organizationId: string) { + const { automations } = useAutomations(organizationId); + const client = useConvexClient(); + const startRun = useConvexAction(api.tasks.public_actions.startTaskWorkflow); + const cancelRun = useConvexAction( + api.tasks.public_actions.cancelTaskWorkflow, + ); + const { t } = useT('tasks'); + const { t: tCommon } = useT('common'); + + return useCallback( + async ( + task: ChoreographedTask, + to: string, + ): Promise => { + const resolved = resolveTaskSubjectContract(task, automations); + if (!resolved) return 'move'; + const { contract } = resolved; + + // Facts, fetched only when the plan can depend on them. + let runActive = false; + if (task.status === 'in_progress') { + const run = await client.query( + api.workflow_executions.queries.getLatestExecutionForSubject, + { organizationId, subjectType: 'task', subjectId: task._id }, + ); + runActive = run != null && isActiveExecutionStatus(run.status); + } + let hasFiles = false; + if ( + to === 'in_progress' && + task.status !== 'in_review' && + contract.input?.kind === 'folder' && + typeof task.externalId === 'string' && + task.externalId !== '' + ) { + const documents = await client.query( + api.projects.queries.listProjectDocuments, + { projectId: task.projectId, organizationId }, + ); + hasFiles = documents.some((doc) => doc.folderId === task.externalId); + } + + const plan = decideTaskStatusTransition({ + contract, + from: task.status, + to, + runActive, + hasFiles, + }); + + switch (plan.kind) { + case 'move': + return 'move'; + case 'block': + toast({ title: t('run.missingInput'), variant: 'destructive' }); + return 'blocked'; + case 'start': + case 'request_changes': + try { + const result = await startRun.mutateAsync({ + organizationId, + taskId: task._id, + workflowSlug: contract.workflow, + }); + if (result.started) { + toast({ title: t('run.started'), variant: 'success' }); + return 'handled'; + } + toast({ + title: + result.reason === 'already_running' + ? t('run.alreadyRunning') + : t('run.notStarted'), + variant: + result.reason === 'already_running' ? undefined : 'destructive', + }); + return 'blocked'; + } catch (error) { + console.error('[tasks] status-choreographed start failed', error); + toast({ title: t('run.notStarted'), variant: 'destructive' }); + return 'blocked'; + } + case 'cancel': + try { + await cancelRun.mutateAsync({ organizationId, taskId: task._id }); + toast({ title: t('run.cancelled') }); + return plan.alsoMove ? 'move' : 'handled'; + } catch (error) { + console.error('[tasks] status-choreographed cancel failed', error); + toast({ title: tCommon('errors.generic'), variant: 'destructive' }); + return 'blocked'; + } + } + }, + [automations, cancelRun, client, organizationId, startRun, t, tCommon], + ); +} diff --git a/services/platform/app/features/tasks/hooks/use-task-subject-contract.test.ts b/services/platform/app/features/tasks/hooks/use-task-subject-contract.test.ts new file mode 100644 index 0000000000..844a262573 --- /dev/null +++ b/services/platform/app/features/tasks/hooks/use-task-subject-contract.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import type { AutomationSummary } from '@/app/features/automations/hooks/use-automations'; + +import { resolveTaskSubjectContract } from './use-task-subject-contract'; + +function automation( + slug: string, + task: unknown, + name = slug, +): AutomationSummary { + return { + slug, + name, + description: '', + scope: 'project', + kind: 'automation', + workflows: [slug], + agents: [], + skills: [], + functions: [], + requiredIntegrations: [], + subjects: { task }, + } as unknown as AutomationSummary; +} + +const VAT_CONTRACT = { + workflow: 'vat-return-desk', + externalSystem: 'vatplus', + input: { kind: 'folder' }, + start: { when: 'hasFiles && status == todo' }, +}; + +describe('resolveTaskSubjectContract', () => { + it('resolves an app-owned task by its creation stamp', () => { + const resolved = resolveTaskSubjectContract( + { createdBy: 'vat-return-desk', createdByType: 'app' }, + [automation('vat-return-desk', VAT_CONTRACT)], + ); + expect(resolved?.automationSlug).toBe('vat-return-desk'); + expect(resolved?.contract.workflow).toBe('vat-return-desk'); + }); + + it('never falls back for app-owned tasks whose owner has no contract', () => { + const resolved = resolveTaskSubjectContract( + { + createdBy: 'other-desk', + createdByType: 'app', + externalSystem: 'vatplus', + }, + [automation('vat-return-desk', VAT_CONTRACT)], + ); + expect(resolved).toBeNull(); + }); + + it('resolves a pre-stamping task via a UNIQUE externalSystem match', () => { + const resolved = resolveTaskSubjectContract( + { + createdBy: 'user_1', + createdByType: 'agent', + externalSystem: 'vatplus', + }, + [automation('vat-return-desk', VAT_CONTRACT)], + ); + expect(resolved?.automationSlug).toBe('vat-return-desk'); + }); + + it('refuses an ambiguous externalSystem match', () => { + const resolved = resolveTaskSubjectContract( + { + createdBy: 'user_1', + createdByType: 'user', + externalSystem: 'vatplus', + }, + [ + automation('vat-return-desk', VAT_CONTRACT), + automation('vat-return-desk-v2', VAT_CONTRACT), + ], + ); + expect(resolved).toBeNull(); + }); + + it('ignores automations with an invalid contract', () => { + const resolved = resolveTaskSubjectContract( + { + createdBy: 'user_1', + createdByType: 'user', + externalSystem: 'vatplus', + }, + [ + automation('broken', { externalSystem: 'vatplus' }), // no workflow + automation('vat-return-desk', VAT_CONTRACT), + ], + ); + expect(resolved?.automationSlug).toBe('vat-return-desk'); + }); + + it('resolves nothing for a plain task', () => { + const resolved = resolveTaskSubjectContract( + { createdBy: 'user_1', createdByType: 'user' }, + [automation('vat-return-desk', VAT_CONTRACT)], + ); + expect(resolved).toBeNull(); + }); +}); diff --git a/services/platform/app/features/tasks/hooks/use-task-subject-contract.ts b/services/platform/app/features/tasks/hooks/use-task-subject-contract.ts new file mode 100644 index 0000000000..0bad9e8b04 --- /dev/null +++ b/services/platform/app/features/tasks/hooks/use-task-subject-contract.ts @@ -0,0 +1,98 @@ +'use client'; + +import { useMemo } from 'react'; + +import { + useAutomations, + type AutomationSummary, +} from '@/app/features/automations/hooks/use-automations'; +import { + taskSubjectContractSchema, + type TaskSubjectContract, +} from '@/lib/shared/schemas/automations'; + +/** The task fields ownership resolution reads (a subset of the task doc). */ +export interface TaskOwnershipFields { + createdBy: string; + createdByType: 'user' | 'agent' | 'app'; + externalSystem?: string; +} + +export interface ResolvedTaskSubjectContract { + automationSlug: string; + contract: TaskSubjectContract; + /** Display fields of the owning automation (template picker labels). */ + name: string; + i18n?: AutomationSummary['i18n']; +} + +/** Installed automations narrowed to the ones declaring a VALID task + * contract (tolerant: an unparsable contract reads as none). */ +export function taskSubjectEntries( + automations: AutomationSummary[], +): ResolvedTaskSubjectContract[] { + return automations.flatMap((a) => { + const parsed = taskSubjectContractSchema.safeParse(a.subjects?.task); + return parsed.success + ? [ + { + automationSlug: a.slug, + contract: parsed.data, + name: a.name, + i18n: a.i18n, + }, + ] + : []; + }); +} + +/** + * Which automation OWNS this task, and under what contract. Ownership is the + * write-once creation stamp (`createdByType: 'app'`, `createdBy: `); + * tasks born before stamping fall back to a UNIQUE `externalSystem` match + * among the installed contracts — ambiguity resolves to none (never guess + * an owner). Pure, so the rule is unit-testable. + */ +export function resolveTaskSubjectContract( + task: TaskOwnershipFields, + automations: AutomationSummary[], +): ResolvedTaskSubjectContract | null { + const entries = taskSubjectEntries(automations); + if (task.createdByType === 'app') { + return entries.find((e) => e.automationSlug === task.createdBy) ?? null; + } + if (task.externalSystem) { + const matches = entries.filter( + (e) => e.contract.externalSystem === task.externalSystem, + ); + if (matches.length === 1) return matches[0]; + } + return null; +} + +/** {@link resolveTaskSubjectContract} over the org's installed automations. */ +export function useTaskSubjectContract( + organizationId: string, + task: TaskOwnershipFields | null | undefined, +): ResolvedTaskSubjectContract | null { + const { automations } = useAutomations(organizationId); + return useMemo( + () => (task ? resolveTaskSubjectContract(task, automations) : null), + [automations, task], + ); +} + +/** The contracts a user can CREATE tasks from on this board (template + * creation) — `create.enabled` contracts of installed automations. */ +export function useTaskSubjectTemplates( + organizationId: string, +): ResolvedTaskSubjectContract[] { + const { automations } = useAutomations(organizationId); + return useMemo( + () => + taskSubjectEntries(automations).filter( + (e) => e.contract.create?.enabled === true, + ), + [automations], + ); +} diff --git a/services/platform/convex/automations/file_actions.ts b/services/platform/convex/automations/file_actions.ts index 80d520a014..9e00a1fe72 100644 --- a/services/platform/convex/automations/file_actions.ts +++ b/services/platform/convex/automations/file_actions.ts @@ -166,6 +166,11 @@ function buildCatalogEntry( // Catalog chips (literal display strings, e.g. "GitHub") — rendered on // the hub card and the automation details header, before install. ...(manifest.labels !== undefined && { labels: manifest.labels }), + // Subject contracts (task surface): consumed by the generic task modal + // (actions row, folder input card, template create) — see + // `taskSubjectContractSchema`. Bundles never declare them. + ...(!manifestDeclaresBundle(manifest) && + manifest.subjects !== undefined && { subjects: manifest.subjects }), }; // A BUNDLE installs its members through one aggregated wizard and does diff --git a/services/platform/convex/tasks/internal_mutations.test.ts b/services/platform/convex/tasks/internal_mutations.test.ts index a4adb097fc..1664b725d1 100644 --- a/services/platform/convex/tasks/internal_mutations.test.ts +++ b/services/platform/convex/tasks/internal_mutations.test.ts @@ -183,6 +183,48 @@ describe('agentUpsertTaskByExternalRef — dedup scope', () => { }); }); +describe('agentUpsertTaskByExternalRef — app ownership without run-on-create', () => { + it("explicit automationSlug stamps the task app-owned (createdByType:'app')", async () => { + const t = convexTest(schema, modules); + const projectA = await seedProject(t, 'Alpha'); + + const res = await t.mutation( + internal.tasks.internal_mutations.agentUpsertTaskByExternalRef, + { + organizationId: ORG, + actorId: 'user_1', + projectId: projectA, + externalSystem: 'vatplus', + externalId: 'folder_2026Q3', + title: 'VAT return — 2026Q3', + externalState: 'open', + dedupeScope: 'project', + automationSlug: 'vat-return-desk', + }, + ); + + expect(res.created).toBe(true); + const task = await t.run(async (ctx) => + ctx.db.get(res.taskId as Id<'tasks'>), + ); + expect(task?.createdByType).toBe('app'); + expect(task?.createdBy).toBe('vat-return-desk'); + }); + + it('without automationSlug the create stays agent-attributed as before', async () => { + const t = convexTest(schema, modules); + const projectA = await seedProject(t, 'Alpha'); + + const res = await upsert(t, projectA, 'owner/repo#500', 'project'); + + const task = await t.run(async (ctx) => + ctx.db.get(res.taskId as Id<'tasks'>), + ); + expect(task?.createdByType).toBe('agent'); + expect(task?.createdBy).toBe('user_1'); + }); +}); + describe('agentUpsertTaskByExternalRef — createIfMissing (update-only reconcile)', () => { it('no-ops when the issue has no task on the board (and needs no projectId)', async () => { const t = convexTest(schema, modules); diff --git a/services/platform/convex/tasks/internal_mutations.ts b/services/platform/convex/tasks/internal_mutations.ts index f9205d1c3d..d3e790c09e 100644 --- a/services/platform/convex/tasks/internal_mutations.ts +++ b/services/platform/convex/tasks/internal_mutations.ts @@ -339,6 +339,11 @@ export const agentUpsertTaskByExternalRef = internalMutation({ * (`createdByType:'app'`, `createdBy:`) — the ownership signal the * generic task loops arbitrate on. */ runWorkflowSlug: v.optional(v.string()), + /** App ownership WITHOUT the run-on-create coupling: attribute the task to + * this automation (`createdByType:'app'`) even when no workflow starts at + * creation — the desks' "create now, Start after the files arrive" shape. + * Wins over the `runWorkflowSlug` derivation when both are present. */ + automationSlug: v.optional(v.string()), /** Dedup scope for the external natural key (see the doc comment): * `'project'` keys on the project (one task per issue per project); * `'org'` (default) keys on the org (one task per issue per org). */ @@ -491,13 +496,15 @@ export const agentUpsertTaskByExternalRef = internalMutation({ // attribute it to the app (createdByType:'app', createdBy:) so the // generic task loops defer to the app's own workflow. Otherwise it's an // agent-authored task as before. - const ownerAutomation = args.runWorkflowSlug - ? await automationOwnerOfWorkflowSlug( - ctx, - args.organizationId, - args.runWorkflowSlug, - ) - : null; + const ownerAutomation = + args.automationSlug ?? + (args.runWorkflowSlug + ? await automationOwnerOfWorkflowSlug( + ctx, + args.organizationId, + args.runWorkflowSlug, + ) + : null); const taskId = await ctx.db.insert('tasks', { organizationId: args.organizationId, projectId, diff --git a/services/platform/convex/tasks/public_actions.ts b/services/platform/convex/tasks/public_actions.ts index 4cc71fe56b..de0879849c 100644 --- a/services/platform/convex/tasks/public_actions.ts +++ b/services/platform/convex/tasks/public_actions.ts @@ -107,6 +107,10 @@ export const createTaskFromExternalIssue = action({ labels: v.optional(v.array(v.string())), /** App workflow slug to run on the newly created task (app-scoped). */ runWorkflowSlug: v.optional(v.string()), + /** Attribute the task to this automation (`createdByType:'app'`) without + * starting anything — template creation from the task board / desk + * "New quarter", where the run comes later via Start. */ + automationSlug: v.optional(v.string()), }, returns: v.object({ taskId: v.string(), @@ -229,6 +233,7 @@ export const createTaskFromExternalIssue = action({ // Attributes the task to the owning app (createdByType:'app') so generic // task automation defers to the app's workflow — see the upsert mutation. runWorkflowSlug: args.runWorkflowSlug, + automationSlug: args.automationSlug, // An explicit project (a project-scoped app) dedups per project so two // projects each get their own task; the org-wide fallback dedups per org. dedupeScope: args.projectId ? 'project' : 'org', diff --git a/services/platform/convex/tasks/queries.ts b/services/platform/convex/tasks/queries.ts index 9070464b42..6d88d60523 100644 --- a/services/platform/convex/tasks/queries.ts +++ b/services/platform/convex/tasks/queries.ts @@ -775,13 +775,40 @@ export const getTaskOpsIndicators = query({ ); const runningTaskIds: Id<'tasks'>[] = []; + const seenRunning = new Set>(); for await (const run of ctx.db .query('taskAgentRuns') .withIndex('by_project_status', (q) => q.eq('projectId', args.projectId).eq('status', 'running'), )) { - runningTaskIds.push(run.taskId); + if (!seenRunning.has(run.taskId)) { + runningTaskIds.push(run.taskId); + seenRunning.add(run.taskId); + } + if (runningTaskIds.length >= TASK_OPS_INDICATOR_CAP) break; + } + // Subject-linked workflow runs (a desk's choreographed executions) pulse + // the SAME working indicator — a status-driven start must read as work in + // flight on the board, not only inside the modal. Active executions are + // capacity-bounded, so the org-wide active scan stays tiny. + for (const status of ['running', 'pending'] as const) { if (runningTaskIds.length >= TASK_OPS_INDICATOR_CAP) break; + for await (const exec of ctx.db + .query('wfExecutions') + .withIndex('by_org_status', (q) => + q.eq('organizationId', args.organizationId).eq('status', status), + )) { + if (runningTaskIds.length >= TASK_OPS_INDICATOR_CAP) break; + if (exec.subjectType !== 'task' || exec.subjectId === undefined) { + continue; + } + const taskId = ctx.db.normalizeId('tasks', exec.subjectId); + if (!taskId || seenRunning.has(taskId)) continue; + const task = await ctx.db.get(taskId); + if (task?.projectId !== args.projectId) continue; + runningTaskIds.push(taskId); + seenRunning.add(taskId); + } } // Pending reviews are rare org-wide, so the org-level pending scan diff --git a/services/platform/lib/shared/schemas/automations.test.ts b/services/platform/lib/shared/schemas/automations.test.ts index dedecdcf17..a8ba858c84 100644 --- a/services/platform/lib/shared/schemas/automations.test.ts +++ b/services/platform/lib/shared/schemas/automations.test.ts @@ -30,6 +30,61 @@ describe('automationManifestSchema — scope', () => { }); }); +describe('automationManifestSchema — subjects.task contract', () => { + const CONTRACT = { + workflow: 'vat-return-desk', + externalSystem: 'vatplus', + input: { + kind: 'folder', + naming: '^\\d{4}Q[1-4]$', + setupFolderName: 'Setup', + }, + create: { + enabled: true, + titleTemplate: 'VAT return — {name}', + field: { + label: 'Quarter folder name', + i18n: { de: { label: 'Name des Quartalsordners' } }, + }, + }, + start: { when: 'hasFiles && status == todo' }, + review: { requestChanges: true }, + }; + + it('parses the full task contract', () => { + const m = automationManifestSchema.parse({ + name: 'Desk', + subjects: { task: CONTRACT }, + }); + expect(m.subjects?.task?.workflow).toBe('vat-return-desk'); + expect(m.subjects?.task?.input?.kind).toBe('folder'); + expect(m.subjects?.task?.create?.enabled).toBe(true); + }); + + it('accepts a manifest with no subjects (back-compat)', () => { + const m = automationManifestSchema.parse({ name: 'Desk' }); + expect(m.subjects).toBeUndefined(); + }); + + it('rejects a contract without a workflow', () => { + expect(() => + automationManifestSchema.parse({ + name: 'Desk', + subjects: { task: { externalSystem: 'vatplus' } }, + }), + ).toThrow(); + }); + + it('rejects a non-folder input kind', () => { + expect(() => + automationManifestSchema.parse({ + name: 'Desk', + subjects: { task: { workflow: 'w', input: { kind: 'url' } } }, + }), + ).toThrow(); + }); +}); + describe('automationManifestSchema — labels', () => { it('parses catalog labels and keeps them optional', () => { const m = automationManifestSchema.parse({ diff --git a/services/platform/lib/shared/schemas/automations.ts b/services/platform/lib/shared/schemas/automations.ts index 9ae9b38814..b37d802a18 100644 --- a/services/platform/lib/shared/schemas/automations.ts +++ b/services/platform/lib/shared/schemas/automations.ts @@ -67,6 +67,69 @@ export type AutomationManifestI18n = z.infer< typeof automationManifestI18nSchema >; +/** Localizable strings of the create-flow's single input field (the + * subject's natural key, e.g. the quarter folder name). Mirrors the view + * form-field grammar so packs can carry the same copy on both surfaces. */ +const taskSubjectFieldTextSchema = z.object({ + label: z.string().optional(), + placeholder: z.string().optional(), + help: z.string().optional(), +}); + +/** + * The automation's TASK-SURFACE contract — one declaration every task surface + * consumes: the desk view, the generic task modal (actions row, folder input + * card, template create), and agent dispatch. A task is bound to its owning + * automation via `createdByType: 'app'` + `createdBy: ` + * (write-once at creation); tasks born before ownership stamping resolve via + * the `externalSystem` namespace instead. + */ +export const taskSubjectContractSchema = z.object({ + /** Workflow the task-surface Start / Request-changes actions run + * (`startTaskWorkflow`; the slug is also the automation slug). */ + workflow: z.string().min(1), + /** Dedup namespace stamped on owned tasks (`tasks.externalSystem`) — also + * the ownership fallback for tasks created before slug stamping. */ + externalSystem: z.string().min(1).optional(), + /** What the task's external binding IS. `folder`: `externalId` holds a + * project root folder id — the task modal swaps its Attachments zone for + * the folder's upload card so input lands where the run reads it. */ + input: z + .object({ + kind: z.literal('folder'), + /** Anchored regex the folder name must match at template-create time + * (e.g. "^\\d{4}Q[1-4]$" for VAT quarters). */ + naming: z.string().optional(), + /** Sibling setup folder resolved into `externalUrl` on create (the + * desks' binding convention; create fails closed when missing). */ + setupFolderName: z.string().optional(), + }) + .optional(), + /** Board-side template creation ("New quarter" from the task board). */ + create: z + .object({ + enabled: z.boolean(), + /** Title derived from the input value; `{name}` is the only token. */ + titleTemplate: z.string().optional(), + /** Literal description for the created task (same copy the desk uses). */ + description: z.string().optional(), + field: taskSubjectFieldTextSchema + .extend({ + i18n: z.record(z.string(), taskSubjectFieldTextSchema).optional(), + }) + .optional(), + }) + .optional(), + /** Start gating over the task facts (`when_predicate` grammar; variables: + * `status`, `hasFiles`). Absent ⇒ Start hidden. */ + start: z.object({ when: z.string().optional() }).optional(), + /** Review affordances: `requestChanges` shows the desk's comment-then-rerun + * action at `in_review` (same `startTaskWorkflow` path as Start). */ + review: z.object({ requestChanges: z.boolean().optional() }).optional(), +}); + +export type TaskSubjectContract = z.infer; + export const automationManifestSchema = z .object({ /** Friendly display name shown in Automations (the slug is the dir name). */ @@ -129,6 +192,16 @@ export const automationManifestSchema = z * Agent refs inside it (`/`) are unaffected. */ workflow: workflowJsonSchema.optional(), + /** + * Subject contracts — how entities this automation OWNS are operated from + * generic surfaces. `task`: the task-surface contract (see + * {@link taskSubjectContractSchema}) consumed by the task modal's actions + * row / folder input card / template create and by agent dispatch, so the + * desk view stops being the only place the flow can complete. + */ + subjects: z + .object({ task: taskSubjectContractSchema.optional() }) + .optional(), /** Agent slugs this automation composes (referenced, they live in agents/). */ agents: z.array(z.string()).optional(), /** diff --git a/services/platform/messages/de.json b/services/platform/messages/de.json index c7bee661cd..412c39fd89 100644 --- a/services/platform/messages/de.json +++ b/services/platform/messages/de.json @@ -990,6 +990,7 @@ "showLess": "Weniger anzeigen", "drop": "Dateien hierher ziehen, oder", "deleteFile": "{name} löschen", + "openFolder": "Ordner in Wissen öffnen", "folderDeleted": "Der Ordner dieses Quartals wurde gelöscht. Legen Sie ihn in Wissen neu an oder entfernen Sie diese Abrechnung." } }, @@ -7429,11 +7430,33 @@ "restoreSuccess": "Aufgabe wiederhergestellt", "restoreError": "Aufgabe konnte nicht wiederhergestellt werden" }, + "create": { + "type": "Typ", + "blank": "Leere Aufgabe", + "name": "Name", + "invalidName": "Dieser Name entspricht nicht dem erwarteten Format.", + "templateCreated": "Angelegt — lege die Eingabedateien auf der neuen Aufgabe ab und drücke dann Start.", + "templateExists": "Gibt es schon — die Aufgabe ist bereits auf dem Board." + }, + "automation": { + "hint": "Wird von {name} betrieben — nach In Arbeit verschieben startet die Automatisierung." + }, + "run": { + "started": "Lauf gestartet.", + "alreadyRunning": "Es läuft bereits ein Lauf.", + "notStarted": "Der Lauf konnte nicht starten.", + "cancelled": "Lauf abgebrochen.", + "missingInput": "Lege zuerst die Eingabedateien ab — der Ordner ist noch leer.", + "hintStart": "Startet den Lauf von {name}.", + "hintRequestChanges": "Rechnet mit dem Kommentar-Feedback neu.", + "hintCancel": "Bricht zuerst den laufenden Lauf ab." + }, "detail": { "overview": "Übersicht", "comments": "Kommentare", "noComments": "Noch keine Kommentare.", "activity": "Aktivität", + "outcome": "Ergebnis", "subtasks": "Teilaufgaben", "addSubtask": "Teilaufgabe hinzufügen…", "addDescription": "Beschreibung hinzufügen…", diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json index eb36b88ccd..157ef9be46 100644 --- a/services/platform/messages/en.json +++ b/services/platform/messages/en.json @@ -990,6 +990,7 @@ "showLess": "Show less", "drop": "Drop files here, or", "deleteFile": "Delete {name}", + "openFolder": "Open folder in Knowledge", "folderDeleted": "This quarter’s folder was deleted. Recreate it in Knowledge, or remove this return." } }, @@ -7720,11 +7721,33 @@ "restoreSuccess": "Task restored", "restoreError": "Couldn't restore task" }, + "create": { + "type": "Type", + "blank": "Blank task", + "name": "Name", + "invalidName": "This name doesn't match the required format.", + "templateCreated": "Created — drop the input files on the new task, then press Start.", + "templateExists": "Already exists — its task is on the board." + }, + "automation": { + "hint": "Operated by {name} — moving it to In progress runs the automation." + }, + "run": { + "started": "Run started.", + "alreadyRunning": "A run is already in progress.", + "notStarted": "The run could not start.", + "cancelled": "Run cancelled.", + "missingInput": "Add the input files first — the folder is still empty.", + "hintStart": "Starts the {name} run.", + "hintRequestChanges": "Reruns with the comment feedback.", + "hintCancel": "Cancels the active run first." + }, "detail": { "overview": "Overview", "comments": "Comments", "noComments": "No comments yet.", "activity": "Activity", + "outcome": "Outcome", "subtasks": "Subtasks", "addSubtask": "Add a subtask…", "addDescription": "Add a description…", diff --git a/services/platform/messages/fr.json b/services/platform/messages/fr.json index 1d058d2245..9332ac92d7 100644 --- a/services/platform/messages/fr.json +++ b/services/platform/messages/fr.json @@ -990,6 +990,7 @@ "showLess": "Afficher moins", "drop": "Déposez les fichiers ici, ou", "deleteFile": "Supprimer {name}", + "openFolder": "Ouvrir le dossier dans Connaissances", "folderDeleted": "Le dossier de ce trimestre a été supprimé. Recréez-le dans Connaissances ou supprimez ce décompte." } }, @@ -7429,11 +7430,33 @@ "restoreSuccess": "Tâche restaurée", "restoreError": "Impossible de restaurer la tâche" }, + "create": { + "type": "Type", + "blank": "Tâche vierge", + "name": "Nom", + "invalidName": "Ce nom ne correspond pas au format attendu.", + "templateCreated": "Créé — dépose les fichiers d'entrée sur la nouvelle tâche, puis appuie sur Démarrer.", + "templateExists": "Existe déjà — sa tâche est déjà sur le tableau." + }, + "automation": { + "hint": "Géré par {name} — passer à En cours lance l'automatisation." + }, + "run": { + "started": "Exécution démarrée.", + "alreadyRunning": "Une exécution est déjà en cours.", + "notStarted": "L'exécution n'a pas pu démarrer.", + "cancelled": "Exécution annulée.", + "missingInput": "Ajoute d'abord les fichiers d'entrée — le dossier est encore vide.", + "hintStart": "Démarre l'exécution de {name}.", + "hintRequestChanges": "Recalcule avec le feedback des commentaires.", + "hintCancel": "Annule d'abord l'exécution en cours." + }, "detail": { "overview": "Aperçu", "comments": "Commentaires", "noComments": "Aucun commentaire pour le moment.", "activity": "Activité", + "outcome": "Résultat", "subtasks": "Sous-tâches", "addSubtask": "Ajouter une sous-tâche…", "addDescription": "Ajouter une description…",