stagent 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +74 -49
  2. package/package.json +3 -2
  3. package/public/readme/cost-usage-list.png +0 -0
  4. package/public/readme/dashboard-bulk-select.png +0 -0
  5. package/public/readme/dashboard-card-edit.png +0 -0
  6. package/public/readme/dashboard-create-form-ai-applied.png +0 -0
  7. package/public/readme/dashboard-create-form-ai-assist.png +0 -0
  8. package/public/readme/dashboard-create-form-empty.png +0 -0
  9. package/public/readme/dashboard-create-form-filled.png +0 -0
  10. package/public/readme/dashboard-filtered.png +0 -0
  11. package/public/readme/dashboard-list.png +0 -0
  12. package/public/readme/dashboard-workflow-confirm.png +0 -0
  13. package/public/readme/home-below-fold.png +0 -0
  14. package/public/readme/home-list.png +0 -0
  15. package/public/readme/inbox-list.png +0 -0
  16. package/public/readme/playbook-list.png +0 -0
  17. package/public/readme/profiles-list.png +0 -0
  18. package/public/readme/settings-list.png +0 -0
  19. package/public/readme/workflows-list.png +0 -0
  20. package/src/__tests__/e2e/blueprint.test.ts +63 -0
  21. package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
  22. package/src/__tests__/e2e/helpers.ts +286 -0
  23. package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
  24. package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
  25. package/src/__tests__/e2e/setup.ts +156 -0
  26. package/src/__tests__/e2e/single-task.test.ts +170 -0
  27. package/src/app/api/command-palette/recent/route.ts +41 -18
  28. package/src/app/api/context/batch/route.ts +44 -0
  29. package/src/app/api/permissions/presets/route.ts +80 -0
  30. package/src/app/api/playbook/status/route.ts +15 -0
  31. package/src/app/api/profiles/route.ts +23 -20
  32. package/src/app/api/settings/pricing/route.ts +15 -0
  33. package/src/app/api/tasks/[id]/route.ts +54 -3
  34. package/src/app/api/workflows/[id]/route.ts +43 -4
  35. package/src/app/api/workflows/[id]/status/route.ts +70 -2
  36. package/src/app/api/workflows/from-assist/route.ts +6 -32
  37. package/src/app/costs/page.tsx +53 -43
  38. package/src/app/dashboard/page.tsx +59 -21
  39. package/src/app/documents/[id]/page.tsx +10 -8
  40. package/src/app/globals.css +11 -0
  41. package/src/app/page.tsx +60 -3
  42. package/src/app/playbook/[slug]/page.tsx +76 -0
  43. package/src/app/playbook/page.tsx +54 -0
  44. package/src/app/profiles/page.tsx +7 -4
  45. package/src/app/settings/page.tsx +2 -2
  46. package/src/app/tasks/[id]/page.tsx +22 -2
  47. package/src/components/costs/cost-dashboard.tsx +226 -320
  48. package/src/components/dashboard/activity-feed.tsx +6 -2
  49. package/src/components/dashboard/greeting.tsx +3 -1
  50. package/src/components/dashboard/priority-queue.tsx +58 -9
  51. package/src/components/dashboard/stats-cards.tsx +16 -2
  52. package/src/components/documents/document-chip-bar.tsx +183 -0
  53. package/src/components/documents/document-content-renderer.tsx +146 -0
  54. package/src/components/documents/document-detail-view.tsx +16 -239
  55. package/src/components/documents/image-zoom-view.tsx +60 -0
  56. package/src/components/documents/smart-extracted-text.tsx +47 -0
  57. package/src/components/documents/utils.ts +70 -0
  58. package/src/components/notifications/batch-proposal-review.tsx +150 -0
  59. package/src/components/notifications/inbox-list.tsx +4 -5
  60. package/src/components/notifications/notification-item.tsx +73 -6
  61. package/src/components/notifications/pending-approval-host.tsx +63 -14
  62. package/src/components/playbook/adoption-heatmap.tsx +69 -0
  63. package/src/components/playbook/journey-card.tsx +110 -0
  64. package/src/components/playbook/playbook-action-button.tsx +22 -0
  65. package/src/components/playbook/playbook-browser.tsx +143 -0
  66. package/src/components/playbook/playbook-card.tsx +102 -0
  67. package/src/components/playbook/playbook-detail-view.tsx +225 -0
  68. package/src/components/playbook/playbook-homepage.tsx +142 -0
  69. package/src/components/playbook/playbook-toc.tsx +90 -0
  70. package/src/components/playbook/playbook-updated-badge.tsx +23 -0
  71. package/src/components/playbook/related-docs.tsx +30 -0
  72. package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
  73. package/src/components/profiles/context-proposal-review.tsx +7 -3
  74. package/src/components/profiles/learned-context-panel.tsx +116 -8
  75. package/src/components/profiles/profile-browser.tsx +1 -0
  76. package/src/components/profiles/profile-card.tsx +16 -8
  77. package/src/components/profiles/profile-detail-view.tsx +12 -4
  78. package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
  79. package/src/components/settings/api-key-form.tsx +5 -43
  80. package/src/components/settings/auth-config-section.tsx +10 -6
  81. package/src/components/settings/auth-status-badge.tsx +8 -0
  82. package/src/components/settings/budget-guardrails-section.tsx +403 -620
  83. package/src/components/settings/connection-test-control.tsx +63 -0
  84. package/src/components/settings/permissions-section.tsx +85 -75
  85. package/src/components/settings/permissions-sections.tsx +24 -0
  86. package/src/components/settings/presets-section.tsx +159 -0
  87. package/src/components/settings/pricing-registry-panel.tsx +164 -0
  88. package/src/components/shared/app-sidebar.tsx +4 -2
  89. package/src/components/shared/command-palette.tsx +30 -0
  90. package/src/components/shared/light-markdown.tsx +134 -0
  91. package/src/components/tasks/__tests__/kanban-board-accessibility.test.tsx +1 -1
  92. package/src/components/tasks/ai-assist-panel.tsx +108 -78
  93. package/src/components/tasks/content-preview.tsx +2 -1
  94. package/src/components/tasks/kanban-board.tsx +57 -5
  95. package/src/components/tasks/kanban-column.tsx +34 -23
  96. package/src/components/tasks/task-bento-cell.tsx +50 -0
  97. package/src/components/tasks/task-bento-grid.tsx +155 -0
  98. package/src/components/tasks/task-card.tsx +14 -16
  99. package/src/components/tasks/task-chip-bar.tsx +207 -0
  100. package/src/components/tasks/task-detail-view.tsx +42 -190
  101. package/src/components/tasks/task-result-renderer.tsx +33 -0
  102. package/src/components/workflows/blueprint-gallery.tsx +19 -12
  103. package/src/components/workflows/blueprint-preview.tsx +8 -1
  104. package/src/components/workflows/loop-status-view.tsx +2 -4
  105. package/src/components/workflows/swarm-dashboard.tsx +2 -3
  106. package/src/components/workflows/workflow-confirmation-view.tsx +2 -7
  107. package/src/components/workflows/workflow-full-output.tsx +80 -0
  108. package/src/components/workflows/workflow-kanban-card.tsx +121 -0
  109. package/src/components/workflows/workflow-list.tsx +47 -42
  110. package/src/components/workflows/workflow-status-view.tsx +163 -16
  111. package/src/lib/agents/learned-context.ts +27 -15
  112. package/src/lib/agents/learning-session.ts +354 -0
  113. package/src/lib/agents/pattern-extractor.ts +19 -0
  114. package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
  115. package/src/lib/agents/profiles/sort.ts +7 -0
  116. package/src/lib/constants/card-icons.tsx +202 -0
  117. package/src/lib/constants/prose-styles.ts +7 -0
  118. package/src/lib/constants/settings.ts +1 -0
  119. package/src/lib/constants/task-status.ts +3 -0
  120. package/src/lib/db/schema.ts +3 -0
  121. package/src/lib/docs/adoption.ts +105 -0
  122. package/src/lib/docs/journey-tracker.ts +21 -0
  123. package/src/lib/docs/reader.ts +107 -0
  124. package/src/lib/docs/types.ts +54 -0
  125. package/src/lib/docs/usage-stage.ts +60 -0
  126. package/src/lib/documents/context-builder.ts +41 -0
  127. package/src/lib/notifications/actionable.ts +18 -10
  128. package/src/lib/queries/chart-data.ts +20 -1
  129. package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
  130. package/src/lib/settings/budget-guardrails.ts +213 -85
  131. package/src/lib/settings/permission-presets.ts +150 -0
  132. package/src/lib/settings/runtime-setup.ts +71 -0
  133. package/src/lib/usage/__tests__/ledger.test.ts +2 -2
  134. package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
  135. package/src/lib/usage/ledger.ts +1 -1
  136. package/src/lib/usage/pricing-registry.ts +570 -0
  137. package/src/lib/usage/pricing.ts +15 -95
  138. package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
  139. package/src/lib/utils/learned-context-history.ts +150 -0
  140. package/src/lib/validators/__tests__/settings.test.ts +23 -16
  141. package/src/lib/validators/settings.ts +3 -9
  142. package/src/lib/workflows/engine.ts +75 -61
  143. package/src/lib/workflows/types.ts +2 -0
  144. package/tsconfig.json +2 -1
  145. package/src/components/documents/document-preview.tsx +0 -68
@@ -73,9 +73,13 @@ export function ActivityFeed({ entries, hourlyActivity }: ActivityFeedProps) {
73
73
  <span className="text-muted-foreground"> — {entry.taskTitle}</span>
74
74
  )}
75
75
  </p>
76
- <p className="text-xs text-muted-foreground">
76
+ <p className="text-xs text-muted-foreground" suppressHydrationWarning>
77
77
  {new Date(entry.timestamp).toLocaleTimeString()}
78
- {entry.payload && ` · ${entry.payload.slice(0, 60)}${entry.payload.length > 60 ? "..." : ""}`}
78
+ {entry.payload && (() => {
79
+ const chars = Array.from(entry.payload);
80
+ const truncated = chars.length > 60 ? chars.slice(0, 60).join("") + "..." : entry.payload;
81
+ return ` · ${truncated}`;
82
+ })()}
79
83
  </p>
80
84
  </div>
81
85
  </div>
@@ -2,6 +2,7 @@ interface GreetingProps {
2
2
  runningCount: number;
3
3
  awaitingCount: number;
4
4
  failedCount: number;
5
+ activeWorkflows: number;
5
6
  }
6
7
 
7
8
  function getGreeting(): string {
@@ -11,9 +12,10 @@ function getGreeting(): string {
11
12
  return "Good evening";
12
13
  }
13
14
 
14
- export function Greeting({ runningCount, awaitingCount, failedCount }: GreetingProps) {
15
+ export function Greeting({ runningCount, awaitingCount, failedCount, activeWorkflows }: GreetingProps) {
15
16
  const parts: string[] = [];
16
17
  if (runningCount > 0) parts.push(`${runningCount} task${runningCount !== 1 ? "s" : ""} running`);
18
+ if (activeWorkflows > 0) parts.push(`${activeWorkflows} workflow${activeWorkflows !== 1 ? "s" : ""} active`);
17
19
  if (awaitingCount > 0) parts.push(`${awaitingCount} awaiting your review`);
18
20
  if (failedCount > 0) parts.push(`${failedCount} failed task${failedCount !== 1 ? "s" : ""} to address`);
19
21
 
@@ -4,8 +4,8 @@ import Link from "next/link";
4
4
  import { Badge } from "@/components/ui/badge";
5
5
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
6
  import { Button } from "@/components/ui/button";
7
- import { AlertTriangle, Clock, Shield, ArrowRight } from "lucide-react";
8
- import { taskStatusVariant } from "@/lib/constants/status-colors";
7
+ import { AlertTriangle, Clock, Shield, ArrowRight, Workflow as WorkflowIcon, FilePen, Pause, CheckCircle2 } from "lucide-react";
8
+ import { taskStatusVariant, workflowStatusVariant } from "@/lib/constants/status-colors";
9
9
 
10
10
  export interface PriorityTask {
11
11
  id: string;
@@ -13,6 +13,14 @@ export interface PriorityTask {
13
13
  status: string;
14
14
  priority: number;
15
15
  projectName?: string;
16
+ isWorkflow?: boolean;
17
+ workflowProgress?: {
18
+ current: number;
19
+ total: number;
20
+ currentStepName?: string;
21
+ workflowId: string;
22
+ workflowStatus: string;
23
+ };
16
24
  }
17
25
 
18
26
  interface PriorityQueueProps {
@@ -24,6 +32,14 @@ const statusIcon: Record<string, typeof AlertTriangle> = {
24
32
  running: Clock,
25
33
  };
26
34
 
35
+ const workflowStatusIcon: Record<string, typeof AlertTriangle> = {
36
+ draft: FilePen,
37
+ active: WorkflowIcon,
38
+ paused: Pause,
39
+ completed: CheckCircle2,
40
+ failed: AlertTriangle,
41
+ };
42
+
27
43
  const priorityColors = ["text-priority-critical", "text-priority-high", "text-status-warning", "text-muted-foreground"];
28
44
 
29
45
  export function PriorityQueue({ tasks }: PriorityQueueProps) {
@@ -40,28 +56,61 @@ export function PriorityQueue({ tasks }: PriorityQueueProps) {
40
56
  className="text-sm text-muted-foreground py-4 text-center"
41
57
  aria-live="polite"
42
58
  >
43
- No tasks need attention right now.
59
+ No tasks or workflows need attention right now.
44
60
  </p>
45
61
  ) : (
46
62
  <div className="space-y-1" aria-live="polite">
47
63
  {tasks.map((task) => {
48
- const Icon = statusIcon[task.status] ?? Shield;
64
+ // Workflow items use workflow-specific status icons; tasks use task status icons
65
+ const Icon = task.isWorkflow
66
+ ? workflowStatusIcon[task.status] ?? WorkflowIcon
67
+ : statusIcon[task.status] ?? Shield;
68
+ const linkHref = task.isWorkflow
69
+ ? `/workflows/${task.workflowProgress?.workflowId ?? task.id}`
70
+ : `/tasks/${task.id}`;
71
+
49
72
  return (
50
- <div
73
+ <Link
51
74
  key={task.id}
52
- className="flex items-center gap-3 py-2.5 border-b border-border/50 last:border-b-0"
75
+ href={linkHref}
76
+ className="flex items-center gap-3 py-2.5 border-b border-border/50 last:border-b-0 hover:bg-accent/30 rounded-md px-1 -mx-1 transition-colors"
53
77
  >
54
- <Icon className={`h-4 w-4 flex-shrink-0 ${priorityColors[task.priority] ?? priorityColors[3]}`} />
78
+ <Icon className={`h-4 w-4 flex-shrink-0 ${
79
+ task.isWorkflow ? "text-primary" : priorityColors[task.priority] ?? priorityColors[3]
80
+ }`} />
55
81
  <div className="flex-1 min-w-0">
56
82
  <p className="text-sm font-medium truncate">{task.title}</p>
57
83
  {task.projectName && (
58
84
  <p className="text-xs text-muted-foreground">{task.projectName}</p>
59
85
  )}
86
+ {task.workflowProgress && (
87
+ <div className="flex items-center gap-1.5 mt-1">
88
+ <span className="text-xs text-muted-foreground">
89
+ {task.workflowProgress.current}/{task.workflowProgress.total}
90
+ </span>
91
+ {task.workflowProgress.currentStepName && (
92
+ <span className="text-[11px] text-muted-foreground truncate">
93
+ {task.workflowProgress.currentStepName}
94
+ </span>
95
+ )}
96
+ {/* Mini progress bar */}
97
+ <div className="h-1 flex-1 max-w-16 rounded-full bg-muted overflow-hidden">
98
+ <div
99
+ className="h-full rounded-full bg-primary transition-all"
100
+ style={{ width: `${(task.workflowProgress.current / task.workflowProgress.total) * 100}%` }}
101
+ />
102
+ </div>
103
+ </div>
104
+ )}
60
105
  </div>
61
- <Badge variant={taskStatusVariant[task.status] ?? "secondary"} className="text-xs">
106
+ <Badge variant={
107
+ task.isWorkflow
108
+ ? workflowStatusVariant[task.status] ?? "secondary"
109
+ : taskStatusVariant[task.status] ?? "secondary"
110
+ } className="text-xs">
62
111
  {task.status}
63
112
  </Badge>
64
- </div>
113
+ </Link>
65
114
  );
66
115
  })}
67
116
  </div>
@@ -2,7 +2,7 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5
- import { Activity, CheckCircle, MessageSquare, FolderKanban } from "lucide-react";
5
+ import { Activity, CheckCircle, MessageSquare, FolderKanban, Workflow } from "lucide-react";
6
6
  import { Sparkline } from "@/components/charts/sparkline";
7
7
 
8
8
  interface StatsCardsProps {
@@ -11,11 +11,13 @@ interface StatsCardsProps {
11
11
  completedAllTime: number;
12
12
  awaitingReview: number;
13
13
  activeProjects: number;
14
+ activeWorkflows: number;
14
15
  sparklines?: {
15
16
  completions: number[];
16
17
  creations: number[];
17
18
  projects: number[];
18
19
  notifications: number[];
20
+ workflows: number[];
19
21
  };
20
22
  }
21
23
 
@@ -25,6 +27,7 @@ export function StatsCards({
25
27
  completedAllTime,
26
28
  awaitingReview,
27
29
  activeProjects,
30
+ activeWorkflows,
28
31
  sparklines,
29
32
  }: StatsCardsProps) {
30
33
  const stats = [
@@ -72,10 +75,21 @@ export function StatsCards({
72
75
  destination: "Projects",
73
76
  sparklineData: sparklines?.projects,
74
77
  },
78
+ {
79
+ title: "Workflows Active",
80
+ value: activeWorkflows,
81
+ subtitle: "In progress",
82
+ icon: Workflow,
83
+ color: "text-primary",
84
+ chartColor: "var(--chart-5)",
85
+ href: "/workflows",
86
+ destination: "Workflows",
87
+ sparklineData: sparklines?.workflows,
88
+ },
75
89
  ];
76
90
 
77
91
  return (
78
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
92
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
79
93
  {stats.map((s) => (
80
94
  <Link key={s.title} href={s.href}>
81
95
  <Card className="surface-card glass-shimmer cursor-pointer transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl">
@@ -0,0 +1,183 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from "@/components/ui/select";
13
+ import {
14
+ Download,
15
+ Trash2,
16
+ Unlink,
17
+ ArrowUpRight,
18
+ ArrowDownLeft,
19
+ Link2,
20
+ FolderKanban,
21
+ } from "lucide-react";
22
+ import {
23
+ getFileIcon,
24
+ formatSize,
25
+ getStatusColor,
26
+ getStatusDotColor,
27
+ formatRelativeTime,
28
+ } from "./utils";
29
+ import type { DocumentWithRelations } from "./types";
30
+
31
+ interface DocumentChipBarProps {
32
+ doc: DocumentWithRelations;
33
+ onDelete: () => void;
34
+ onUnlinkTask: () => void;
35
+ onLinkProject: (projectId: string) => void;
36
+ projects: { id: string; name: string }[];
37
+ deleting: boolean;
38
+ linking: boolean;
39
+ }
40
+
41
+ export function DocumentChipBar({
42
+ doc,
43
+ onDelete,
44
+ onUnlinkTask,
45
+ onLinkProject,
46
+ projects,
47
+ deleting,
48
+ linking,
49
+ }: DocumentChipBarProps) {
50
+ const router = useRouter();
51
+ const Icon = getFileIcon(doc.mimeType);
52
+ const DirectionIcon = doc.direction === "output" ? ArrowUpRight : ArrowDownLeft;
53
+
54
+ return (
55
+ <div className="surface-control rounded-lg p-4 space-y-3">
56
+ {/* Row 1: Name + Actions */}
57
+ <div className="flex items-center gap-3 min-w-0">
58
+ <Icon className="h-5 w-5 text-muted-foreground shrink-0" />
59
+ <h1 className="text-lg font-semibold truncate flex-1 min-w-0">
60
+ {doc.originalName}
61
+ </h1>
62
+ <div className="flex items-center gap-2 shrink-0">
63
+ <Button variant="outline" size="sm" asChild>
64
+ <a
65
+ href={`/api/documents/${doc.id}/file`}
66
+ target="_blank"
67
+ rel="noopener noreferrer"
68
+ >
69
+ <Download className="h-3.5 w-3.5 mr-1" />
70
+ Download
71
+ </a>
72
+ </Button>
73
+ <Button
74
+ variant="destructive"
75
+ size="sm"
76
+ onClick={onDelete}
77
+ disabled={deleting}
78
+ >
79
+ <Trash2 className="h-3.5 w-3.5 mr-1" />
80
+ Delete
81
+ </Button>
82
+ </div>
83
+ </div>
84
+
85
+ {/* Row 2: Metadata chips */}
86
+ <div className="flex flex-wrap items-center gap-2">
87
+ {/* MIME type */}
88
+ <Badge variant="outline" className="text-xs font-normal">
89
+ {doc.mimeType}
90
+ </Badge>
91
+
92
+ {/* File size */}
93
+ <Badge variant="outline" className="text-xs font-normal">
94
+ {formatSize(doc.size)}
95
+ </Badge>
96
+
97
+ {/* Status */}
98
+ <Badge variant="outline" className={`text-xs ${getStatusColor(doc.status)}`}>
99
+ <span className={`w-1.5 h-1.5 rounded-full mr-1.5 ${getStatusDotColor(doc.status)}`} />
100
+ {doc.status}
101
+ </Badge>
102
+
103
+ {/* Direction */}
104
+ <Badge variant="outline" className="text-xs font-normal">
105
+ <DirectionIcon className="h-3 w-3 mr-1" />
106
+ {doc.direction}
107
+ </Badge>
108
+
109
+ {/* Version (output only) */}
110
+ {doc.direction === "output" && (
111
+ <Badge variant="outline" className="text-xs font-normal">
112
+ v{doc.version}
113
+ </Badge>
114
+ )}
115
+
116
+ {/* Created date */}
117
+ <Badge
118
+ variant="outline"
119
+ className="text-xs font-normal"
120
+ title={new Date(doc.createdAt).toLocaleString()}
121
+ >
122
+ {formatRelativeTime(
123
+ typeof doc.createdAt === "number"
124
+ ? doc.createdAt
125
+ : new Date(doc.createdAt).getTime()
126
+ )}
127
+ </Badge>
128
+ </div>
129
+
130
+ {/* Row 3: Links — task, workflow, project */}
131
+ <div className="flex flex-wrap items-center gap-2">
132
+ {/* Task link */}
133
+ {doc.taskTitle ? (
134
+ <Badge
135
+ variant="secondary"
136
+ className="text-xs cursor-pointer hover:bg-accent gap-1"
137
+ onClick={() => doc.taskId && router.push(`/tasks/${doc.taskId}`)}
138
+ >
139
+ <Link2 className="h-3 w-3" />
140
+ {doc.taskTitle}
141
+ <button
142
+ onClick={(e) => {
143
+ e.stopPropagation();
144
+ onUnlinkTask();
145
+ }}
146
+ className="ml-1 hover:text-destructive"
147
+ aria-label="Unlink from task"
148
+ >
149
+ <Unlink className="h-3 w-3" />
150
+ </button>
151
+ </Badge>
152
+ ) : (
153
+ <Badge variant="outline" className="text-xs font-normal text-muted-foreground">
154
+ <Link2 className="h-3 w-3 mr-1" />
155
+ No task
156
+ </Badge>
157
+ )}
158
+
159
+ {/* Project selector */}
160
+ <div className="flex items-center gap-1.5">
161
+ <FolderKanban className="h-3.5 w-3.5 text-muted-foreground" />
162
+ <Select
163
+ value={doc.projectId ?? "none"}
164
+ onValueChange={onLinkProject}
165
+ disabled={linking}
166
+ >
167
+ <SelectTrigger className="h-7 w-[220px] text-xs border-dashed">
168
+ <SelectValue placeholder="Project" />
169
+ </SelectTrigger>
170
+ <SelectContent>
171
+ <SelectItem value="none">No project</SelectItem>
172
+ {projects.map((p) => (
173
+ <SelectItem key={p.id} value={p.id}>
174
+ {p.name}
175
+ </SelectItem>
176
+ ))}
177
+ </SelectContent>
178
+ </Select>
179
+ </div>
180
+ </div>
181
+ </div>
182
+ );
183
+ }
@@ -0,0 +1,146 @@
1
+ "use client";
2
+
3
+ import { memo } from "react";
4
+ import ReactMarkdown from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import { Loader2, Download, FileQuestion } from "lucide-react";
7
+ import { Badge } from "@/components/ui/badge";
8
+ import { Button } from "@/components/ui/button";
9
+ import { ErrorState } from "@/components/shared/error-state";
10
+ import { EmptyState } from "@/components/shared/empty-state";
11
+ import { ImageZoomView } from "./image-zoom-view";
12
+ import { SmartExtractedText } from "./smart-extracted-text";
13
+ import {
14
+ isMarkdown,
15
+ isCode,
16
+ isPlainText,
17
+ isPdf,
18
+ isImage,
19
+ getLanguageLabel,
20
+ } from "./utils";
21
+ import { PROSE_READER } from "@/lib/constants/prose-styles";
22
+ import type { DocumentWithRelations } from "./types";
23
+
24
+ interface DocumentContentRendererProps {
25
+ doc: DocumentWithRelations;
26
+ }
27
+
28
+ function DocumentContentRendererInner({ doc }: DocumentContentRendererProps) {
29
+ const fileUrl = `/api/documents/${doc.id}/file?inline=1`;
30
+
31
+ // Tier 0: Processing status
32
+ if (doc.status === "processing") {
33
+ return (
34
+ <div className="flex flex-col items-center justify-center gap-3 py-20">
35
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
36
+ <p className="text-sm text-muted-foreground">Processing document...</p>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ // Tier 0: Error status
42
+ if (doc.status === "error") {
43
+ return (
44
+ <ErrorState
45
+ heading="Processing failed"
46
+ description={doc.processingError ?? "An unknown error occurred while processing this document."}
47
+ />
48
+ );
49
+ }
50
+
51
+ // Tier 1: Markdown — full render, no truncation
52
+ if (isMarkdown(doc.mimeType) && doc.extractedText) {
53
+ return (
54
+ <div className={PROSE_READER}>
55
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
56
+ {doc.extractedText}
57
+ </ReactMarkdown>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ // Tier 1: Code — monospace with language label
63
+ if (isCode(doc.mimeType) && doc.extractedText) {
64
+ return (
65
+ <div className="relative">
66
+ <Badge
67
+ variant="secondary"
68
+ className="absolute top-3 right-3 text-xs"
69
+ >
70
+ {getLanguageLabel(doc.mimeType)}
71
+ </Badge>
72
+ <pre className="font-mono text-sm bg-muted p-4 rounded-lg overflow-x-auto whitespace-pre-wrap break-words">
73
+ {doc.extractedText}
74
+ </pre>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ // Tier 1: Plain text — flowing prose paragraphs
80
+ if (isPlainText(doc.mimeType) && doc.extractedText) {
81
+ const paragraphs = doc.extractedText.split(/\n{2,}/).filter(Boolean);
82
+ return (
83
+ <div className={`${PROSE_READER} space-y-4`}>
84
+ {paragraphs.map((p, i) => (
85
+ <p key={i} className="whitespace-pre-wrap">
86
+ {p}
87
+ </p>
88
+ ))}
89
+ </div>
90
+ );
91
+ }
92
+
93
+ // Tier 2: PDF — full-height iframe
94
+ if (isPdf(doc.mimeType)) {
95
+ return (
96
+ <div className="rounded-lg overflow-hidden border border-border">
97
+ <iframe
98
+ src={fileUrl}
99
+ className="w-full min-h-[60vh] md:min-h-[80vh]"
100
+ title={doc.originalName}
101
+ onError={() => {
102
+ // iframe onerror doesn't fire reliably — the fallback link below covers this
103
+ }}
104
+ />
105
+ <div className="p-3 bg-muted/30 text-center">
106
+ <a
107
+ href={`/api/documents/${doc.id}/file`}
108
+ download
109
+ className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
110
+ >
111
+ PDF not loading? Download instead
112
+ </a>
113
+ </div>
114
+ </div>
115
+ );
116
+ }
117
+
118
+ // Tier 2: Image — full size with zoom
119
+ if (isImage(doc.mimeType)) {
120
+ return <ImageZoomView src={fileUrl} alt={doc.originalName} />;
121
+ }
122
+
123
+ // Tier 3: Other with extracted text — smart formatting
124
+ if (doc.extractedText) {
125
+ return <SmartExtractedText text={doc.extractedText} />;
126
+ }
127
+
128
+ // Tier 4: Fallback — no content available
129
+ return (
130
+ <EmptyState
131
+ icon={FileQuestion}
132
+ heading="No preview available"
133
+ description="This file type can't be previewed. Download it to view the contents."
134
+ action={
135
+ <Button variant="outline" size="sm" asChild>
136
+ <a href={`/api/documents/${doc.id}/file`} download>
137
+ <Download className="h-3.5 w-3.5 mr-1" />
138
+ Download File
139
+ </a>
140
+ </Button>
141
+ }
142
+ />
143
+ );
144
+ }
145
+
146
+ export const DocumentContentRenderer = memo(DocumentContentRendererInner);