feed-the-machine 1.1.0 → 1.3.0

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 (92) hide show
  1. package/bin/generate-manifest.mjs +253 -0
  2. package/bin/install.mjs +372 -26
  3. package/docs/INBOX.md +233 -0
  4. package/ftm/SKILL.md +34 -0
  5. package/ftm-audit/SKILL.md +69 -0
  6. package/ftm-brainstorm/SKILL.md +51 -0
  7. package/ftm-browse/SKILL.md +39 -0
  8. package/ftm-capture/SKILL.md +370 -0
  9. package/ftm-capture.yml +4 -0
  10. package/ftm-codex-gate/SKILL.md +59 -0
  11. package/ftm-config/SKILL.md +35 -0
  12. package/ftm-council/SKILL.md +56 -0
  13. package/ftm-dashboard/SKILL.md +34 -0
  14. package/ftm-debug/SKILL.md +84 -0
  15. package/ftm-diagram/SKILL.md +44 -0
  16. package/ftm-executor/SKILL.md +97 -0
  17. package/ftm-git/SKILL.md +60 -0
  18. package/ftm-inbox/backend/__init__.py +0 -0
  19. package/ftm-inbox/backend/adapters/__init__.py +0 -0
  20. package/ftm-inbox/backend/adapters/_retry.py +64 -0
  21. package/ftm-inbox/backend/adapters/base.py +230 -0
  22. package/ftm-inbox/backend/adapters/freshservice.py +104 -0
  23. package/ftm-inbox/backend/adapters/gmail.py +125 -0
  24. package/ftm-inbox/backend/adapters/jira.py +136 -0
  25. package/ftm-inbox/backend/adapters/registry.py +192 -0
  26. package/ftm-inbox/backend/adapters/slack.py +110 -0
  27. package/ftm-inbox/backend/db/__init__.py +0 -0
  28. package/ftm-inbox/backend/db/connection.py +54 -0
  29. package/ftm-inbox/backend/db/schema.py +78 -0
  30. package/ftm-inbox/backend/executor/__init__.py +7 -0
  31. package/ftm-inbox/backend/executor/engine.py +149 -0
  32. package/ftm-inbox/backend/executor/step_runner.py +98 -0
  33. package/ftm-inbox/backend/main.py +103 -0
  34. package/ftm-inbox/backend/models/__init__.py +1 -0
  35. package/ftm-inbox/backend/models/unified_task.py +36 -0
  36. package/ftm-inbox/backend/planner/__init__.py +6 -0
  37. package/ftm-inbox/backend/planner/generator.py +127 -0
  38. package/ftm-inbox/backend/planner/schema.py +34 -0
  39. package/ftm-inbox/backend/requirements.txt +5 -0
  40. package/ftm-inbox/backend/routes/__init__.py +0 -0
  41. package/ftm-inbox/backend/routes/execute.py +186 -0
  42. package/ftm-inbox/backend/routes/health.py +52 -0
  43. package/ftm-inbox/backend/routes/inbox.py +68 -0
  44. package/ftm-inbox/backend/routes/plan.py +271 -0
  45. package/ftm-inbox/bin/launchagent.mjs +91 -0
  46. package/ftm-inbox/bin/setup.mjs +188 -0
  47. package/ftm-inbox/bin/start.sh +10 -0
  48. package/ftm-inbox/bin/status.sh +17 -0
  49. package/ftm-inbox/bin/stop.sh +8 -0
  50. package/ftm-inbox/config.example.yml +55 -0
  51. package/ftm-inbox/package-lock.json +2898 -0
  52. package/ftm-inbox/package.json +26 -0
  53. package/ftm-inbox/postcss.config.js +6 -0
  54. package/ftm-inbox/src/app.css +199 -0
  55. package/ftm-inbox/src/app.html +18 -0
  56. package/ftm-inbox/src/lib/api.ts +166 -0
  57. package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -0
  58. package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -0
  59. package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -0
  60. package/ftm-inbox/src/lib/components/PlanView.svelte +206 -0
  61. package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -0
  62. package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -0
  63. package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -0
  64. package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -0
  65. package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -0
  66. package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -0
  67. package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -0
  68. package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -0
  69. package/ftm-inbox/src/lib/theme.ts +47 -0
  70. package/ftm-inbox/src/routes/+layout.svelte +76 -0
  71. package/ftm-inbox/src/routes/+page.svelte +401 -0
  72. package/ftm-inbox/static/favicon.png +0 -0
  73. package/ftm-inbox/svelte.config.js +12 -0
  74. package/ftm-inbox/tailwind.config.ts +63 -0
  75. package/ftm-inbox/tsconfig.json +13 -0
  76. package/ftm-inbox/vite.config.ts +6 -0
  77. package/ftm-intent/SKILL.md +44 -0
  78. package/ftm-manifest.json +3794 -0
  79. package/ftm-map/SKILL.md +50 -0
  80. package/ftm-mind/SKILL.md +173 -66
  81. package/ftm-pause/SKILL.md +43 -0
  82. package/ftm-researcher/SKILL.md +55 -0
  83. package/ftm-resume/SKILL.md +47 -0
  84. package/ftm-retro/SKILL.md +54 -0
  85. package/ftm-routine/SKILL.md +36 -0
  86. package/ftm-state/blackboard/capabilities.json +5 -0
  87. package/ftm-state/blackboard/capabilities.schema.json +27 -0
  88. package/ftm-upgrade/SKILL.md +41 -0
  89. package/hooks/ftm-blackboard-enforcer.sh +28 -27
  90. package/hooks/ftm-plan-gate.sh +21 -25
  91. package/install.sh +238 -111
  92. package/package.json +6 -2
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "ftm-inbox",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite dev",
8
+ "build": "vite build",
9
+ "preview": "vite preview",
10
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
11
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
12
+ },
13
+ "devDependencies": {
14
+ "@sveltejs/adapter-auto": "^3.0.0",
15
+ "@sveltejs/kit": "^2.0.0",
16
+ "@sveltejs/vite-plugin-svelte": "^3.0.0",
17
+ "autoprefixer": "^10.4.16",
18
+ "postcss": "^8.4.32",
19
+ "svelte": "^4.2.7",
20
+ "svelte-check": "^3.6.0",
21
+ "tailwindcss": "^3.4.0",
22
+ "tslib": "^2.4.1",
23
+ "typescript": "^5.0.0",
24
+ "vite": "^5.0.3"
25
+ }
26
+ }
@@ -0,0 +1,6 @@
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {}
5
+ }
6
+ };
@@ -0,0 +1,199 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* ─── Kawaii CSS Custom Properties ─── */
6
+
7
+ .theme-light {
8
+ --bg-primary: #e8f5e9;
9
+ --bg-secondary: #f1f8f1;
10
+ --bg-card: #fefefe;
11
+ --bg-sidebar: #f5fbf5;
12
+ --bg-drawer: #fafafa;
13
+ --border-accent: #a5d6a7;
14
+ --border-card: #c8e6c9;
15
+ --text-primary: #1b3a1b;
16
+ --text-secondary: #4a7a4a;
17
+ --text-muted: #7aaa7a;
18
+ --accent-primary: #4caf50;
19
+ --accent-hover: #388e3c;
20
+ --shadow-card: 0 4px 24px 0 rgba(76, 175, 80, 0.10);
21
+ --shadow-card-hover: 0 6px 32px 0 rgba(76, 175, 80, 0.18);
22
+ --grid-color: rgba(76, 175, 80, 0.07);
23
+ --scrollbar-thumb: #c8e6c9;
24
+ --scrollbar-track: #f1f8f1;
25
+ }
26
+
27
+ .theme-dark {
28
+ --bg-primary: #1b2e1b;
29
+ --bg-secondary: #1e331e;
30
+ --bg-card: #243824;
31
+ --bg-sidebar: #1e331e;
32
+ --bg-drawer: #1a2a1a;
33
+ --border-accent: #69f0ae;
34
+ --border-card: #2e7d32;
35
+ --text-primary: #e8f5e9;
36
+ --text-secondary: #a5d6a7;
37
+ --text-muted: #66bb6a;
38
+ --accent-primary: #69f0ae;
39
+ --accent-hover: #b2ffdb;
40
+ --shadow-card: 0 0 0 1px #69f0ae33, 0 4px 24px 0 rgba(105, 240, 174, 0.12);
41
+ --shadow-card-hover: 0 0 0 2px #69f0ae55, 0 6px 32px 0 rgba(105, 240, 174, 0.22);
42
+ --grid-color: rgba(105, 240, 174, 0.05);
43
+ --scrollbar-thumb: #2e7d32;
44
+ --scrollbar-track: #1b2e1b;
45
+ }
46
+
47
+ /* ─── Base Styles ─── */
48
+
49
+ *,
50
+ *::before,
51
+ *::after {
52
+ box-sizing: border-box;
53
+ }
54
+
55
+ html {
56
+ height: 100%;
57
+ font-size: 16px;
58
+ }
59
+
60
+ body {
61
+ margin: 0;
62
+ padding: 0;
63
+ min-height: 100vh;
64
+ background-color: var(--bg-primary);
65
+ color: var(--text-primary);
66
+ font-family: 'Nunito', 'Quicksand', sans-serif;
67
+ transition: background-color 0.3s ease, color 0.3s ease;
68
+ /* Graph-paper grid background */
69
+ background-image:
70
+ linear-gradient(var(--grid-color) 1px, transparent 1px),
71
+ linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
72
+ background-size: 30px 30px;
73
+ }
74
+
75
+ /* ─── Scrollbar ─── */
76
+
77
+ ::-webkit-scrollbar {
78
+ width: 6px;
79
+ height: 6px;
80
+ }
81
+ ::-webkit-scrollbar-track {
82
+ background: var(--scrollbar-track);
83
+ }
84
+ ::-webkit-scrollbar-thumb {
85
+ background: var(--scrollbar-thumb);
86
+ border-radius: 9999px;
87
+ }
88
+
89
+ /* ─── Typography ─── */
90
+
91
+ h1, h2, h3, h4, h5, h6 {
92
+ font-family: 'Nunito', sans-serif;
93
+ font-weight: 800;
94
+ color: var(--text-primary);
95
+ margin: 0;
96
+ }
97
+
98
+ p {
99
+ margin: 0;
100
+ line-height: 1.6;
101
+ }
102
+
103
+ /* ─── Kawaii Card ─── */
104
+
105
+ .card-kawaii {
106
+ background: var(--bg-card);
107
+ border: 2px solid var(--border-card);
108
+ border-radius: 16px;
109
+ box-shadow: var(--shadow-card);
110
+ transition: box-shadow 0.2s ease, transform 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55);
111
+ }
112
+
113
+ .card-kawaii:hover {
114
+ box-shadow: var(--shadow-card-hover);
115
+ }
116
+
117
+ /* ─── Pill Button ─── */
118
+
119
+ .btn-pill {
120
+ display: inline-flex;
121
+ align-items: center;
122
+ gap: 0.5rem;
123
+ padding: 0.5rem 1.25rem;
124
+ border-radius: 9999px;
125
+ font-family: 'Nunito', sans-serif;
126
+ font-weight: 700;
127
+ font-size: 0.875rem;
128
+ cursor: pointer;
129
+ border: none;
130
+ transition: transform 0.18s cubic-bezier(0.68, -0.55, 0.265, 1.55),
131
+ box-shadow 0.18s ease,
132
+ background-color 0.18s ease;
133
+ user-select: none;
134
+ }
135
+
136
+ .btn-pill:hover {
137
+ transform: scale(1.06) translateY(-1px);
138
+ }
139
+
140
+ .btn-pill:active {
141
+ transform: scale(0.96);
142
+ }
143
+
144
+ .btn-pill-primary {
145
+ background-color: var(--accent-primary);
146
+ color: #fff;
147
+ box-shadow: 0 2px 8px rgba(76, 175, 80, 0.3);
148
+ }
149
+
150
+ .btn-pill-primary:hover {
151
+ background-color: var(--accent-hover);
152
+ box-shadow: 0 4px 16px rgba(76, 175, 80, 0.4);
153
+ }
154
+
155
+ .btn-pill-ghost {
156
+ background-color: transparent;
157
+ color: var(--text-secondary);
158
+ border: 2px solid var(--border-card);
159
+ }
160
+
161
+ .btn-pill-ghost:hover {
162
+ border-color: var(--accent-primary);
163
+ color: var(--accent-primary);
164
+ }
165
+
166
+ /* ─── Status Badge Colors ─── */
167
+
168
+ .badge-pending { background: #fff9c4; color: #5d4037; }
169
+ .badge-planning { background: #bbdefb; color: #0d47a1; }
170
+ .badge-approved { background: #c8e6c9; color: #1b5e20; }
171
+ .badge-executing { background: #ffe0b2; color: #bf360c; }
172
+ .badge-complete { background: #b2dfdb; color: #004d40; }
173
+ .badge-failed { background: #ffcdd2; color: #b71c1c; }
174
+
175
+ /* ─── Animations ─── */
176
+
177
+ @keyframes bounceIn {
178
+ 0% { opacity: 0; transform: scale(0.8); }
179
+ 100% { opacity: 1; transform: scale(1); }
180
+ }
181
+
182
+ @keyframes fadeUp {
183
+ 0% { opacity: 0; transform: translateY(8px); }
184
+ 100% { opacity: 1; transform: translateY(0); }
185
+ }
186
+
187
+ @keyframes slideDown {
188
+ 0% { opacity: 0; transform: translateY(-8px); }
189
+ 100% { opacity: 1; transform: translateY(0); }
190
+ }
191
+
192
+ @keyframes drawerOpen {
193
+ 0% { transform: translateY(100%); }
194
+ 100% { transform: translateY(0); }
195
+ }
196
+
197
+ .animate-bounce-in { animation: bounceIn 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55) both; }
198
+ .animate-fade-up { animation: fadeUp 0.3s ease-out both; }
199
+ .animate-slide-down { animation: slideDown 0.3s ease-out both; }
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="icon" href="%sveltekit.assets%/favicon.png" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,400;0,600;0,700;0,800;1,400&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ %sveltekit.head%
14
+ </head>
15
+ <body data-sveltekit-preload-data="hover" class="theme-light">
16
+ <div style="display: contents">%sveltekit.body%</div>
17
+ </body>
18
+ </html>
@@ -0,0 +1,166 @@
1
+ /**
2
+ * API client for the FTM Inbox backend.
3
+ */
4
+
5
+ const API_BASE = 'http://localhost:8042';
6
+
7
+ export interface UnifiedTask {
8
+ id: number;
9
+ source: string;
10
+ source_id: string;
11
+ title: string;
12
+ body: string;
13
+ status: string;
14
+ priority: string;
15
+ assignee: string | null;
16
+ requester: string | null;
17
+ created_at: string | null;
18
+ updated_at: string | null;
19
+ tags: string[];
20
+ custom_fields: Record<string, unknown>;
21
+ source_url: string | null;
22
+ content_hash: string | null;
23
+ ingested_at: string | null;
24
+ }
25
+
26
+ export interface InboxResponse {
27
+ tasks: UnifiedTask[];
28
+ total: number;
29
+ page: number;
30
+ per_page: number;
31
+ }
32
+
33
+ export async function fetchInbox(source?: string, page = 1): Promise<InboxResponse> {
34
+ const params = new URLSearchParams({ page: String(page) });
35
+ if (source && source !== 'all') params.set('source', source);
36
+ const res = await fetch(`${API_BASE}/api/inbox?${params}`);
37
+ if (!res.ok) throw new Error(`API error: ${res.status}`);
38
+ return res.json();
39
+ }
40
+
41
+ export async function fetchSources(): Promise<{ name: string; count: number }[]> {
42
+ const res = await fetch(`${API_BASE}/api/inbox/sources`);
43
+ if (!res.ok) return [];
44
+ const data = await res.json();
45
+ return data.sources ?? [];
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Plan types
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export interface PlanStep {
53
+ id: number;
54
+ title: string;
55
+ target_system: string;
56
+ method_primary: string;
57
+ method_fallback: string;
58
+ risk_level: string;
59
+ approval_required: boolean;
60
+ rollback: string;
61
+ status: string;
62
+ }
63
+
64
+ export interface Plan {
65
+ id: number;
66
+ task_id: number;
67
+ steps: PlanStep[];
68
+ status: string;
69
+ yaml_content: string;
70
+ created_at: string | null;
71
+ updated_at: string | null;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Plan API functions
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export async function generatePlan(taskId: number): Promise<Plan> {
79
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}/generate-plan`, {
80
+ method: 'POST'
81
+ });
82
+ if (!res.ok) {
83
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
84
+ throw new Error(err.detail ?? `API error: ${res.status}`);
85
+ }
86
+ return res.json();
87
+ }
88
+
89
+ export async function getPlan(taskId: number): Promise<Plan | null> {
90
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}/plan`);
91
+ if (!res.ok) return null;
92
+ const data = await res.json();
93
+ // Backend returns { plan: null } when no plan exists
94
+ if ('plan' in data && data.plan === null) return null;
95
+ return data as Plan;
96
+ }
97
+
98
+ export async function approveStep(taskId: number, stepId: number): Promise<Plan> {
99
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}/plan/steps/${stepId}/approve`, {
100
+ method: 'POST'
101
+ });
102
+ if (!res.ok) {
103
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
104
+ throw new Error(err.detail ?? `API error: ${res.status}`);
105
+ }
106
+ return res.json();
107
+ }
108
+
109
+ export async function approveAllSteps(taskId: number): Promise<Plan> {
110
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}/plan/approve-all`, {
111
+ method: 'POST'
112
+ });
113
+ if (!res.ok) {
114
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
115
+ throw new Error(err.detail ?? `API error: ${res.status}`);
116
+ }
117
+ return res.json();
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Execution types
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export interface AuditEntry {
125
+ id: number;
126
+ step_id: string;
127
+ action_type: string;
128
+ target_system: string;
129
+ target_object: string;
130
+ mutation_performed: string;
131
+ result: Record<string, unknown>;
132
+ rollback_available: boolean;
133
+ created_at: string;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Execution API functions
138
+ // ---------------------------------------------------------------------------
139
+
140
+ export async function startExecution(taskId: number): Promise<Record<string, unknown>> {
141
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}/execute`, { method: 'POST' });
142
+ if (!res.ok) {
143
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
144
+ throw new Error(err.detail ?? `API error: ${res.status}`);
145
+ }
146
+ return res.json();
147
+ }
148
+
149
+ export async function pauseExecution(taskId: number): Promise<void> {
150
+ await fetch(`${API_BASE}/api/tasks/${taskId}/pause`, { method: 'POST' });
151
+ }
152
+
153
+ export async function resumeExecution(taskId: number): Promise<void> {
154
+ await fetch(`${API_BASE}/api/tasks/${taskId}/resume`, { method: 'POST' });
155
+ }
156
+
157
+ export async function retryStep(taskId: number, stepId: number): Promise<void> {
158
+ await fetch(`${API_BASE}/api/tasks/${taskId}/steps/${stepId}/retry`, { method: 'POST' });
159
+ }
160
+
161
+ export async function getAuditLog(taskId: number): Promise<AuditEntry[]> {
162
+ const res = await fetch(`${API_BASE}/api/tasks/${taskId}/audit-log`);
163
+ if (!res.ok) return [];
164
+ const data = await res.json();
165
+ return data.entries ?? [];
166
+ }
@@ -0,0 +1,81 @@
1
+ <script lang="ts">
2
+ import type { AuditEntry } from '$lib/api';
3
+
4
+ export let entries: AuditEntry[] = [];
5
+
6
+ const levelColors: Record<string, string> = {
7
+ info: '#66bb6a',
8
+ warn: '#ffd54f',
9
+ success: '#4caf50',
10
+ error: '#ef5350'
11
+ };
12
+
13
+ function levelFromAction(entry: AuditEntry): string {
14
+ const result = entry.result as Record<string, unknown>;
15
+ if (result?.status === 'failed') return 'error';
16
+ if (result?.status === 'completed') return 'success';
17
+ return 'info';
18
+ }
19
+
20
+ function formatTime(dateStr: string): string {
21
+ try {
22
+ return new Date(dateStr).toLocaleTimeString('en-GB', { hour12: false });
23
+ } catch {
24
+ return dateStr;
25
+ }
26
+ }
27
+ </script>
28
+
29
+ <div class="exec-log">
30
+ {#each entries as entry (entry.id)}
31
+ {@const level = levelFromAction(entry)}
32
+ <div class="log-entry" style="border-left-color: {levelColors[level] ?? '#66bb6a'}">
33
+ <span class="log-time">{formatTime(entry.created_at)}</span>
34
+ <span class="log-action">{entry.action_type}</span>
35
+ <span class="log-target">{entry.target_object}</span>
36
+ </div>
37
+ {/each}
38
+ </div>
39
+
40
+ <style>
41
+ .exec-log {
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: 0.25rem;
45
+ }
46
+
47
+ .log-entry {
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: 0.1rem;
51
+ padding: 0.35rem 0.5rem;
52
+ border-radius: 8px;
53
+ font-size: 0.72rem;
54
+ border-left: 3px solid #66bb6a;
55
+ transition: background 0.1s;
56
+ }
57
+
58
+ .log-entry:hover {
59
+ background: rgba(76, 175, 80, 0.04);
60
+ }
61
+
62
+ .log-time {
63
+ font-family: 'Menlo', monospace;
64
+ font-size: 0.65rem;
65
+ color: var(--text-muted);
66
+ }
67
+
68
+ .log-action {
69
+ font-weight: 700;
70
+ color: var(--text-secondary);
71
+ text-transform: uppercase;
72
+ font-size: 0.6rem;
73
+ letter-spacing: 0.05em;
74
+ }
75
+
76
+ .log-target {
77
+ color: var(--text-primary);
78
+ font-weight: 600;
79
+ line-height: 1.4;
80
+ }
81
+ </style>
@@ -0,0 +1,143 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy, createEventDispatcher } from 'svelte';
3
+ import TaskCard from './TaskCard.svelte';
4
+ import PillButton from './ui/PillButton.svelte';
5
+ import EmptyState from './ui/EmptyState.svelte';
6
+ import { fetchInbox, type UnifiedTask } from '$lib/api';
7
+
8
+ export let selectedTaskId: number | null = null;
9
+
10
+ const dispatch = createEventDispatcher<{
11
+ selectTask: UnifiedTask;
12
+ generatePlan: UnifiedTask;
13
+ }>();
14
+
15
+ const sources = ['all', 'jira', 'freshservice', 'slack', 'gmail'] as const;
16
+ let activeSource: string = 'all';
17
+ let tasks: UnifiedTask[] = [];
18
+ let total = 0;
19
+ let loading = true;
20
+ let error = '';
21
+ let interval: ReturnType<typeof setInterval>;
22
+
23
+ async function loadTasks() {
24
+ try {
25
+ const res = await fetchInbox(activeSource === 'all' ? undefined : activeSource);
26
+ tasks = res.tasks;
27
+ total = res.total;
28
+ error = '';
29
+ } catch (e) {
30
+ error = e instanceof Error ? e.message : 'Failed to load';
31
+ } finally {
32
+ loading = false;
33
+ }
34
+ }
35
+
36
+ function switchSource(source: string) {
37
+ activeSource = source;
38
+ loading = true;
39
+ loadTasks();
40
+ }
41
+
42
+ onMount(() => {
43
+ loadTasks();
44
+ interval = setInterval(loadTasks, 15000);
45
+ });
46
+
47
+ onDestroy(() => {
48
+ if (interval) clearInterval(interval);
49
+ });
50
+ </script>
51
+
52
+ <div class="inbox-feed">
53
+ <!-- Source tabs -->
54
+ <div class="source-tabs">
55
+ {#each sources as source}
56
+ <PillButton
57
+ variant={activeSource === source ? 'primary' : 'ghost'}
58
+ size="sm"
59
+ on:click={() => switchSource(source)}
60
+ >
61
+ {source === 'all' ? 'All' : source.charAt(0).toUpperCase() + source.slice(1)}
62
+ </PillButton>
63
+ {/each}
64
+ </div>
65
+
66
+ <!-- Task count -->
67
+ {#if !loading && tasks.length > 0}
68
+ <div class="task-count">{total} task{total !== 1 ? 's' : ''}</div>
69
+ {/if}
70
+
71
+ <!-- Task list -->
72
+ <div class="task-list">
73
+ {#if loading}
74
+ <div class="loading-placeholder">
75
+ {#each Array(3) as _}
76
+ <div class="skeleton-card"></div>
77
+ {/each}
78
+ </div>
79
+ {:else if error}
80
+ <EmptyState emoji="⚠️" title="Connection error" message={error} />
81
+ {:else if tasks.length === 0}
82
+ <EmptyState emoji="📭" title="No tasks yet" message="Enjoy the quiet! Tasks will appear when pollers find them." />
83
+ {:else}
84
+ {#each tasks as task (task.id)}
85
+ <TaskCard
86
+ {task}
87
+ selected={selectedTaskId === task.id}
88
+ on:select={(e) => dispatch('selectTask', e.detail)}
89
+ on:generatePlan={(e) => dispatch('generatePlan', e.detail)}
90
+ />
91
+ {/each}
92
+ {/if}
93
+ </div>
94
+ </div>
95
+
96
+ <style>
97
+ .inbox-feed {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: 0.5rem;
101
+ height: 100%;
102
+ }
103
+
104
+ .source-tabs {
105
+ display: flex;
106
+ gap: 0.35rem;
107
+ flex-wrap: wrap;
108
+ padding-bottom: 0.25rem;
109
+ }
110
+
111
+ .task-count {
112
+ font-size: 0.68rem;
113
+ font-weight: 700;
114
+ color: var(--text-muted);
115
+ letter-spacing: 0.04em;
116
+ }
117
+
118
+ .task-list {
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 0.5rem;
122
+ flex: 1;
123
+ overflow-y: auto;
124
+ }
125
+
126
+ .loading-placeholder {
127
+ display: flex;
128
+ flex-direction: column;
129
+ gap: 0.5rem;
130
+ }
131
+
132
+ .skeleton-card {
133
+ height: 80px;
134
+ background: var(--bg-secondary);
135
+ border-radius: 12px;
136
+ animation: pulse 1.5s ease-in-out infinite;
137
+ }
138
+
139
+ @keyframes pulse {
140
+ 0%, 100% { opacity: 0.4; }
141
+ 50% { opacity: 0.7; }
142
+ }
143
+ </style>