feat(I): I-MINI-MANIFEST/RENDER/SDK + I-WORK-PROACTIVE/E2E frontend — MiniAppBlock, MiniAppSDK, ProactiveFeed, Workstream page, i18n, tsc clean
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Workstream page — central view rendering all workstream block types.
|
||||
*
|
||||
* Block types: text, entity_card, action_card, evidence_card, approval_card,
|
||||
* miniapp, workflow_status, workflow_handoff.
|
||||
*
|
||||
* Backend: app/ai/workstream_contract.py, app/workflows/workstream.py
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
MessageSquare,
|
||||
Link2,
|
||||
AlertTriangle,
|
||||
Workflow,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { MiniAppBlock } from '@/components/workstream/MiniAppBlock';
|
||||
import {
|
||||
EntityCard,
|
||||
ActionButtons,
|
||||
ApprovalCard,
|
||||
ProgressIndicator,
|
||||
DeepLink,
|
||||
type ActionButtonItem,
|
||||
} from '@/components/workstream/MiniAppSDK';
|
||||
import { ProactiveFeed } from '@/components/workstream/ProactiveFeed';
|
||||
import type {
|
||||
WorkstreamMessage,
|
||||
WorkstreamBlock,
|
||||
ProactiveSuggestion,
|
||||
} from '@/api/miniapps';
|
||||
|
||||
interface WorkstreamPageProps {
|
||||
messages?: WorkstreamMessage[];
|
||||
suggestions?: ProactiveSuggestion[];
|
||||
isLoading?: boolean;
|
||||
onApprove?: (approvalId: string) => void;
|
||||
onReject?: (approvalId: string) => void;
|
||||
onAction?: (action: ActionButtonItem) => void;
|
||||
onSuggestionAction?: (suggestion: ProactiveSuggestion) => void;
|
||||
onDismissSuggestion?: (id: string) => void;
|
||||
}
|
||||
|
||||
const ACTOR_VARIANT: Record<
|
||||
string,
|
||||
'secondary' | 'primary' | 'info' | 'success'
|
||||
> = {
|
||||
human: 'secondary',
|
||||
system: 'info',
|
||||
agent: 'primary',
|
||||
workflow: 'success',
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a single workstream block by type.
|
||||
*/
|
||||
function BlockView({
|
||||
block,
|
||||
onApprove,
|
||||
onReject,
|
||||
onAction,
|
||||
}: {
|
||||
block: WorkstreamBlock;
|
||||
onApprove?: (approvalId: string) => void;
|
||||
onReject?: (approvalId: string) => void;
|
||||
onAction?: (action: ActionButtonItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const meta = block.metadata ?? {};
|
||||
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<div className="text-sm whitespace-pre-wrap break-words text-secondary-800">
|
||||
{block.content || String(meta.text ?? '')}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'entity_card':
|
||||
return (
|
||||
<EntityCard
|
||||
entityType={String(meta.entity_type ?? '')}
|
||||
entityId={String(meta.entity_id ?? '')}
|
||||
title={String(meta.title ?? block.content)}
|
||||
subtitle={String(meta.subtitle ?? '')}
|
||||
url={String(meta.url ?? '')}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'action_card': {
|
||||
const actions: ActionButtonItem[] = Array.isArray(meta.actions)
|
||||
? (meta.actions as ActionButtonItem[])
|
||||
: [];
|
||||
return (
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm space-y-2">
|
||||
<h4 className="text-sm font-semibold text-secondary-900">
|
||||
{String(meta.title ?? block.content)}
|
||||
</h4>
|
||||
{meta.description ? (
|
||||
<p className="text-sm text-secondary-600">
|
||||
{String(meta.description)}
|
||||
</p>
|
||||
) : null}
|
||||
<ActionButtons actions={actions} onAction={onAction} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'evidence_card':
|
||||
return (
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4 text-primary-500" aria-hidden="true" />
|
||||
<h4 className="text-sm font-semibold text-secondary-900">
|
||||
{String(meta.title ?? block.content)}
|
||||
</h4>
|
||||
{typeof meta.confidence === 'number' && (
|
||||
<Badge variant="secondary">
|
||||
{Math.round(meta.confidence * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{meta.snippet ? (
|
||||
<p className="mt-2 text-xs text-secondary-500">
|
||||
{String(meta.snippet)}
|
||||
</p>
|
||||
) : null}
|
||||
{meta.url ? (
|
||||
<div className="mt-2">
|
||||
<DeepLink href={String(meta.url)} label={t('workstream.block.openSource')} external />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'approval_card':
|
||||
return (
|
||||
<ApprovalCard
|
||||
approvalId={String(meta.approval_id ?? '')}
|
||||
action={String(meta.action ?? block.content)}
|
||||
description={String(meta.description ?? '')}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'miniapp':
|
||||
return <MiniAppBlock block={block} />;
|
||||
|
||||
case 'workflow_status':
|
||||
return (
|
||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Workflow className="h-4 w-4 text-primary-500" aria-hidden="true" />
|
||||
<h4 className="text-sm font-semibold text-secondary-900">
|
||||
{String(meta.workflow_name ?? block.content)}
|
||||
</h4>
|
||||
<Badge variant="info">{String(meta.status ?? '')}</Badge>
|
||||
</div>
|
||||
{meta.step_name ? (
|
||||
<p className="mt-2 text-xs text-secondary-500">
|
||||
{t('workstream.block.step')}: {String(meta.step_name)}
|
||||
</p>
|
||||
) : null}
|
||||
{typeof meta.step_index === 'number' && (
|
||||
<div className="mt-3">
|
||||
<ProgressIndicator
|
||||
label={t('workstream.block.progress')}
|
||||
value={Math.min(100, (meta.step_index + 1) * 10)}
|
||||
status="in_progress"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'workflow_handoff':
|
||||
return (
|
||||
<div className="border border-warning-200 rounded-lg p-4 bg-warning-50 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowRight className="h-4 w-4 text-warning-700" aria-hidden="true" />
|
||||
<h4 className="text-sm font-semibold text-secondary-900">
|
||||
{t('workstream.block.handoff')}: {String(meta.handoff_type ?? '')}
|
||||
</h4>
|
||||
</div>
|
||||
{meta.description ? (
|
||||
<p className="mt-2 text-xs text-secondary-600">
|
||||
{String(meta.description)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<div className="border border-danger-200 rounded-lg p-4 bg-danger-50 shadow-sm flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-danger-600 mt-0.5" aria-hidden="true" />
|
||||
<div className="text-sm text-danger-700">
|
||||
{block.content || String(meta.error ?? '')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50">
|
||||
{t('workstream.block.unknown', { type: block.type })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Central Workstream page.
|
||||
*/
|
||||
export function WorkstreamPage({
|
||||
messages = [],
|
||||
suggestions = [],
|
||||
isLoading = false,
|
||||
onApprove,
|
||||
onReject,
|
||||
onAction,
|
||||
onSuggestionAction,
|
||||
onDismissSuggestion,
|
||||
}: WorkstreamPageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-4 space-y-6">
|
||||
<header className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-primary-500" aria-hidden="true" />
|
||||
<h1 className="text-lg font-semibold text-secondary-900">
|
||||
{t('workstream.title')}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<ProactiveFeed
|
||||
suggestions={suggestions}
|
||||
onAction={onSuggestionAction}
|
||||
onDismiss={onDismissSuggestion}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin h-6 w-6 text-primary-500" aria-hidden="true" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<MessageSquare className="h-8 w-8" aria-hidden="true" />}
|
||||
title={t('workstream.empty.title')}
|
||||
description={t('workstream.empty.description')}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, idx) => (
|
||||
<article
|
||||
key={message.actor_id ? `${message.actor_id}-${idx}` : idx}
|
||||
className="border border-secondary-200 rounded-lg bg-white shadow-sm p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant={ACTOR_VARIANT[message.actor_type] ?? 'secondary'}>
|
||||
{t(`workstream.actor.${message.actor_type}`)}
|
||||
</Badge>
|
||||
{message.content && (
|
||||
<span className="text-sm text-secondary-600 truncate">
|
||||
{message.content}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{message.blocks && message.blocks.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{message.blocks.map((block, bidx) => (
|
||||
<BlockView
|
||||
key={bidx}
|
||||
block={block}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onAction={onAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkstreamPage;
|
||||
Reference in New Issue
Block a user