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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ export interface AutomationSummary {
members?: string[];
/** role token -> composite agent slug (the manifest's cast). */
roles?: Record<string, string>;
/**
* 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[];
/**
Expand Down Expand Up @@ -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) ?? [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<HTMLInputElement | null>(null);
// null = follow the data (open while empty); a manual toggle pins it.
const [openOverride, setOpenOverride] = useState<boolean | null>(null);
Expand Down Expand Up @@ -150,8 +183,10 @@ export function FolderUploadCard({
>
<FolderInput className="size-4" aria-hidden />
</Row>
<Text as="span" className="font-semibold">
{t('input.title')} {loaded ? `(${count})` : ''}
<Text as="span" className="min-w-0 truncate font-semibold">
{t('input.title')}
{folderName ? ` — ${folderName}` : ''}{' '}
{loaded ? `(${count})` : ''}
</Text>
<ChevronRightIcon
className={`text-muted-foreground ml-auto size-4 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
Expand Down Expand Up @@ -268,6 +303,17 @@ export function FolderUploadCard({
{t('input.upload')}
</Button>
</div>
{showFolderName && folder.data && projectId !== undefined && (
<Link
to="/dashboard/$id/projects/$projectId/files"
params={{ id: organizationId, projectId }}
search={{ folderId }}
className="text-muted-foreground hover:text-foreground focus-visible:ring-primary inline-flex items-center gap-1.5 self-start rounded-sm text-sm underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
>
<FolderInput className="size-4 shrink-0" aria-hidden />
{t('input.openFolder')}
</Link>
)}
</VStack>
)}
</section>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ describe('ResourceDetail — task subject composition', () => {

expect(taskCommentsCalls[0]).toMatchObject({
composerHint: 'automations.detail.commentsDuringRun',
order: 'desc',
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -121,15 +131,21 @@ 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;
}) {
const { t } = useT('operator');
const [preview, setPreview] = useState<PreviewTarget | null>(null);
const steps = outcomeSteps(projection);
// No pack annotation → omit entirely (not an empty card).
if (steps.length === 0) return null;

const runInFlight =
Expand Down Expand Up @@ -221,6 +237,50 @@ export function OutcomeStrip({
const showSettledEmpty =
!hasReadyOrError && !hasPendingSlot && rows.length === 0;

return (
<>
{showSettledEmpty && (
<Text variant="muted">
{t('outcome.empty', {
defaultValue:
'No results yet — they will appear here once a run produces them.',
})}
</Text>
)}

{rows.length > 0 && (
<ul
className="flex flex-col gap-2"
role={hasPendingSlot ? 'status' : undefined}
>
{rows}
</ul>
)}

<DocumentPreviewDialog
open={preview !== null}
onOpenChange={(open) => {
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.
Expand Down Expand Up @@ -248,34 +308,8 @@ export function OutcomeStrip({
)}
</Row>
<VStack gap={3} className="px-5 pb-5">
{showSettledEmpty && (
<Text variant="muted">
{t('outcome.empty', {
defaultValue:
'No results yet — they will appear here once a run produces them.',
})}
</Text>
)}

{rows.length > 0 && (
<ul
className="flex flex-col gap-2"
role={hasPendingSlot ? 'status' : undefined}
>
{rows}
</ul>
)}
<OutcomeRows projection={projection} />
</VStack>

<DocumentPreviewDialog
open={preview !== null}
onOpenChange={(open) => {
if (!open) setPreview(null);
}}
documentId={preview?.documentId}
fileId={preview?.fileId}
fileName={preview?.fileName}
/>
</section>
</Card>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <automation> run."). Hosts
* derive it; the picker stays a dumb control. */
optionDescription?: (status: TaskStatus) => string | undefined;
}) {
const { t } = useT('tasks');
const { t: tCommon } = useT('common');
Expand All @@ -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 = (
Expand All @@ -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) ? <TaskStatusBadge status={opt.value} /> : null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<TaskAutomationBadge organizationId="org_1" task={TASK} />,
);
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(<TaskAutomationBadge organizationId="org_1" task={TASK} showName />);
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(<TaskAutomationBadge organizationId="org_1" task={TASK} />);
expect(screen.queryByText('Swiss VAT return desk')).toBeNull();
expect(screen.getByLabelText('automation.hint')).toBeInTheDocument();
});
});
Loading
Loading