@aion0/forge 0.5.23 → 0.5.24
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.
- package/RELEASE_NOTES.md +19 -6
- package/app/api/smith-templates/route.ts +81 -0
- package/components/WorkspaceView.tsx +841 -83
- package/lib/claude-sessions.ts +2 -1
- package/lib/forge-mcp-server.ts +247 -33
- package/lib/help-docs/11-workspace.md +722 -166
- package/lib/telegram-bot.ts +1 -1
- package/lib/workspace/orchestrator.ts +263 -76
- package/lib/workspace/presets.ts +535 -58
- package/lib/workspace/requests.ts +287 -0
- package/lib/workspace/session-monitor.ts +4 -3
- package/lib/workspace/types.ts +1 -0
- package/lib/workspace/watch-manager.ts +1 -1
- package/lib/workspace-standalone.ts +1 -1
- package/package.json +1 -1
- package/scripts/bench/README.md +66 -0
- package/scripts/bench/results/.gitignore +2 -0
- package/scripts/bench/run.ts +635 -0
- package/scripts/bench/tasks/01-text-utils/task.md +26 -0
- package/scripts/bench/tasks/01-text-utils/validator.sh +46 -0
- package/scripts/bench/tasks/02-pagination/setup.sh +19 -0
- package/scripts/bench/tasks/02-pagination/task.md +48 -0
- package/scripts/bench/tasks/02-pagination/validator.sh +69 -0
- package/scripts/bench/tasks/03-bug-fix/setup.sh +82 -0
- package/scripts/bench/tasks/03-bug-fix/task.md +30 -0
- package/scripts/bench/tasks/03-bug-fix/validator.sh +29 -0
- package/templates/smith-lead.json +45 -0
|
@@ -32,7 +32,7 @@ interface AgentConfig {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
interface AgentState {
|
|
35
|
-
smithStatus: 'down' | 'active';
|
|
35
|
+
smithStatus: 'down' | 'starting' | 'active';
|
|
36
36
|
taskStatus: 'idle' | 'running' | 'done' | 'failed';
|
|
37
37
|
currentStep?: number;
|
|
38
38
|
tmuxSession?: string;
|
|
@@ -69,98 +69,100 @@ const TASK_STATUS: Record<string, { label: string; color: string; glow?: boolean
|
|
|
69
69
|
|
|
70
70
|
const PRESET_AGENTS: Omit<AgentConfig, 'id'>[] = [
|
|
71
71
|
{
|
|
72
|
-
label: '
|
|
73
|
-
|
|
72
|
+
label: 'Lead', icon: '👑', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/lead/'],
|
|
73
|
+
primary: true, persistentSession: true, plugins: ['playwright', 'shell-command'],
|
|
74
|
+
role: `Lead — primary coordinator (recommended for Primary smith). Context auto-includes Workspace Team (all agents, roles, status, missing roles).
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
SOP: Intake → HAS Architect? delegate via create_request : break down yourself → HAS Engineer? create_request(open) : implement in src/ → HAS QA? auto-notified : test yourself → HAS Reviewer? auto-notified : review yourself.
|
|
77
|
+
|
|
78
|
+
SOP: Monitor → get_status + list_requests → stuck/failed agents: send_message or take over → unclaimed requests: nudge Engineers.
|
|
79
|
+
|
|
80
|
+
SOP: Quality Gate → ALL requests done + review=approved + qa=passed → write docs/lead/delivery-summary.md.
|
|
81
|
+
|
|
82
|
+
Gap coverage: missing PM → you break requirements; missing Engineer → you code; missing QA → you test; missing Reviewer → you review. Every delegation uses create_request with acceptance_criteria.`,
|
|
82
83
|
steps: [
|
|
83
|
-
{ id: '
|
|
84
|
-
{ id: '
|
|
85
|
-
{ id: '
|
|
86
|
-
{ id: '
|
|
84
|
+
{ id: 'intake', label: 'Intake & Analyze', prompt: 'Read Workspace Team in context. Identify present/missing roles and incoming requirements. Classify scope and plan delegation vs self-handling.' },
|
|
85
|
+
{ id: 'delegate', label: 'Create Requests & Route', prompt: 'create_request for each task with acceptance_criteria. Route to Architect/Engineer or note for self-implementation. Verify with list_requests.' },
|
|
86
|
+
{ id: 'cover-gaps', label: 'Cover Missing Roles', prompt: 'Implement/test/review for any missing role. update_response for each section you cover.' },
|
|
87
|
+
{ id: 'monitor', label: 'Monitor & Unblock', prompt: 'get_status + list_requests. Unblock stuck agents via send_message or take over their work.' },
|
|
88
|
+
{ id: 'gate', label: 'Quality Gate & Summary', prompt: 'Verify all requests done/approved/passed. Write docs/lead/delivery-summary.md.' },
|
|
87
89
|
],
|
|
88
90
|
},
|
|
89
91
|
{
|
|
90
|
-
label: '
|
|
91
|
-
role: `
|
|
92
|
+
label: 'PM', icon: '📋', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/prd/'],
|
|
93
|
+
role: `Product Manager. Context auto-includes Workspace Team.
|
|
92
94
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
95
|
+
SOP: Read upstream input → list docs/prd/ for version history → identify NEW vs covered → create NEW versioned PRD (never overwrite).
|
|
96
|
+
|
|
97
|
+
PRD structure: version + date, summary, goals, user stories with testable acceptance_criteria, constraints, out of scope, open questions.
|
|
98
|
+
|
|
99
|
+
Version: patch (v1.0.1) = clarification, minor (v1.1) = new feature, major (v2.0) = scope overhaul.
|
|
100
|
+
|
|
101
|
+
Handoff: Do NOT create request docs or write code. Architect/Lead reads docs/prd/ downstream.`,
|
|
102
|
+
steps: [
|
|
103
|
+
{ id: 'analyze', label: 'Analyze Requirements', prompt: 'Read Workspace Team. Read upstream input. List docs/prd/ for version history. Identify NEW vs already covered requirements. Decide version number.' },
|
|
104
|
+
{ id: 'write-prd', label: 'Write PRD', prompt: 'Create NEW versioned file in docs/prd/. Include testable acceptance criteria for every user story. Never overwrite existing PRD files.' },
|
|
105
|
+
{ id: 'self-review', label: 'Self-Review', prompt: 'Checklist: criteria testable by QA? Edge cases? Scope clear for Engineer? No duplication? Fix issues.' },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
label: 'Engineer', icon: '🔨', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['src/', 'docs/architecture/'],
|
|
110
|
+
role: `Senior Software Engineer. Context auto-includes Workspace Team.
|
|
111
|
+
|
|
112
|
+
SOP: Find Work → list_requests(status: "open") → claim_request → get_request for details.
|
|
113
|
+
SOP: Implement → read acceptance_criteria → design (docs/architecture/) → code (src/) → self-test.
|
|
114
|
+
SOP: Report → update_response(section: "engineer", data: {files_changed, notes}) → auto-notifies QA/Reviewer.
|
|
115
|
+
|
|
116
|
+
IF claim fails (already taken) → pick next open request.
|
|
117
|
+
IF blocked by unclear requirement → send_message to upstream (Architect/PM/Lead) with specific question.
|
|
118
|
+
IF no open requests → check inbox for direct assignments.
|
|
119
|
+
|
|
120
|
+
Rules: always claim before starting, always update_response when done, follow existing conventions, architecture docs versioned (never overwrite).`,
|
|
102
121
|
steps: [
|
|
103
|
-
{ id: '
|
|
104
|
-
{ id: '
|
|
105
|
-
{ id: '
|
|
122
|
+
{ id: 'claim', label: 'Find & Claim', prompt: 'Read Workspace Team. Check inbox. list_requests(status: "open"). claim_request on highest priority. If none, check inbox.' },
|
|
123
|
+
{ id: 'design', label: 'Design', prompt: 'get_request for details. Read acceptance_criteria. Read existing code + docs/architecture/. Create new architecture doc if significant change.' },
|
|
124
|
+
{ id: 'implement', label: 'Implement', prompt: 'Implement per design. Follow conventions. Track files changed. Run existing tests. Verify against each acceptance_criterion.' },
|
|
125
|
+
{ id: 'report', label: 'Report Done', prompt: 'update_response(section: "engineer") with files_changed and notes. If blocked, send_message upstream.' },
|
|
106
126
|
],
|
|
107
127
|
},
|
|
108
128
|
{
|
|
109
|
-
label: 'QA', icon: '🧪', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['tests/', 'docs/
|
|
129
|
+
label: 'QA', icon: '🧪', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['tests/', 'docs/qa/'],
|
|
110
130
|
plugins: ['playwright', 'shell-command'],
|
|
111
|
-
role: `QA Engineer
|
|
131
|
+
role: `QA Engineer. Context auto-includes Workspace Team.
|
|
112
132
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
- Check for performance: N+1 queries, unbounded loops, memory leaks
|
|
122
|
-
|
|
123
|
-
Test setup (do this automatically if not already present):
|
|
124
|
-
- If no playwright.config.ts exists, create one based on the project's framework and structure
|
|
125
|
-
- If no tests/e2e/ directory exists, create it
|
|
126
|
-
- Detect the app's dev server command and base URL from package.json or project config
|
|
127
|
-
- Ensure the dev server is running before testing (start it if needed)
|
|
128
|
-
|
|
129
|
-
How to run Playwright tests:
|
|
130
|
-
- Run tests: npx playwright test tests/e2e/ --reporter=line
|
|
131
|
-
- Run headed (visible browser): npx playwright test tests/e2e/ --headed --reporter=line
|
|
132
|
-
- Screenshot: npx playwright screenshot http://localhost:3000 /tmp/screenshot.png
|
|
133
|
-
- Check URL: curl -sf -o /dev/null -w '%{http_code}' http://localhost:3000
|
|
134
|
-
- Run project tests: npm test
|
|
135
|
-
|
|
136
|
-
If Forge MCP tools are available (run_plugin, send_message), prefer those.
|
|
137
|
-
Otherwise use bash commands directly — you have full terminal access.`,
|
|
133
|
+
SOP: Find Work → list_requests(status: "qa") → get_request → read acceptance_criteria + engineer's files_changed.
|
|
134
|
+
SOP: Test → map each criterion to test cases → write Playwright tests in tests/e2e/ → run via run_plugin or npx playwright.
|
|
135
|
+
SOP: Report → update_response(section: "qa", data: {result, test_files, findings}).
|
|
136
|
+
|
|
137
|
+
IF result=passed → auto-advances, no message needed.
|
|
138
|
+
IF result=failed → classify: CRITICAL/MAJOR → ONE send_message to Engineer. MINOR → report only, no message.
|
|
139
|
+
|
|
140
|
+
Rules: never fix bugs (report only), each test traces to acceptance_criterion, max 1 consolidated message, no messages during planning/writing steps.`,
|
|
138
141
|
steps: [
|
|
139
|
-
{ id: '
|
|
140
|
-
{ id: 'plan', label: 'Test Plan', prompt: '
|
|
141
|
-
{ id: 'write-tests', label: 'Write Tests', prompt: 'Write Playwright
|
|
142
|
-
{ id: '
|
|
142
|
+
{ id: 'find-work', label: 'Find Work', prompt: 'Read Workspace Team. Check inbox. list_requests(status: "qa"). get_request for acceptance_criteria and engineer notes.' },
|
|
143
|
+
{ id: 'plan', label: 'Test Plan', prompt: 'Map each criterion to test cases (happy path + edge + error). Write docs/qa/test-plan. Skip already-tested unchanged features.' },
|
|
144
|
+
{ id: 'write-tests', label: 'Write Tests', prompt: 'Write Playwright tests in tests/e2e/. Create config if missing. No messages in this step.' },
|
|
145
|
+
{ id: 'execute', label: 'Execute & Report', prompt: 'Run tests. Record pass/fail per criterion. update_response(section: qa). If critical/major failures: ONE send_message to Engineer.' },
|
|
143
146
|
],
|
|
144
147
|
},
|
|
145
148
|
{
|
|
146
|
-
label: 'Reviewer', icon: '
|
|
147
|
-
role: `
|
|
149
|
+
label: 'Reviewer', icon: '🔍', backend: 'cli', agentId: 'claude', dependsOn: [], outputs: ['docs/review/'],
|
|
150
|
+
role: `Code Reviewer. Context auto-includes Workspace Team.
|
|
148
151
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
152
|
+
SOP: Find Work → list_requests(status: "review") → get_request → read request + engineer response + QA results.
|
|
153
|
+
SOP: Review each file in files_changed → check: criteria met? code quality? security (OWASP)? performance? → classify CRITICAL/MAJOR/MINOR.
|
|
154
|
+
SOP: Verdict → approved (all good) / changes_requested (issues) / rejected (security/data).
|
|
155
|
+
SOP: Report → update_response(section: "review", data: {result, findings}) → write docs/review/.
|
|
156
|
+
|
|
157
|
+
IF approved → auto-advances to done, no message.
|
|
158
|
+
IF changes_requested → ONE send_message to Engineer with top issues.
|
|
159
|
+
IF rejected → send_message to Engineer AND Lead.
|
|
160
|
+
|
|
161
|
+
Rules: never modify code, review only files_changed (not entire codebase), actionable feedback ("change X to Y because Z"), MINOR findings in report only.`,
|
|
159
162
|
steps: [
|
|
160
|
-
{ id: '
|
|
161
|
-
{ id: 'review
|
|
162
|
-
{ id: '
|
|
163
|
-
{ id: 'report', label: 'Final Report', prompt: 'Write docs/review.md: Summary verdict (APPROVE / REQUEST_CHANGES / REJECT). List all findings grouped by severity (CRITICAL → MAJOR → MINOR). For each: file, line, issue, suggested fix. End with an overall assessment and recommendation.' },
|
|
163
|
+
{ id: 'find-work', label: 'Find Work', prompt: 'Read Workspace Team. Check inbox. list_requests(status: "review"). get_request for full context.' },
|
|
164
|
+
{ id: 'review', label: 'Code Review', prompt: 'Review each file in files_changed: criteria met? quality? security? performance? Classify CRITICAL/MAJOR/MINOR.' },
|
|
165
|
+
{ id: 'report', label: 'Verdict & Report', prompt: 'Decide verdict. update_response(section: review). Write docs/review/. If changes_requested/rejected: ONE message to Engineer (+ Lead if rejected).' },
|
|
164
166
|
],
|
|
165
167
|
},
|
|
166
168
|
{
|
|
@@ -634,6 +636,10 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
634
636
|
}));
|
|
635
637
|
setAvailableAgents(list);
|
|
636
638
|
}).catch(() => {});
|
|
639
|
+
// Fetch saved smith templates
|
|
640
|
+
fetch('/api/smith-templates').then(r => r.json()).then(data => {
|
|
641
|
+
setSavedTemplates(data.templates || []);
|
|
642
|
+
}).catch(() => {});
|
|
637
643
|
// Fetch both: plugin definitions + installed instances
|
|
638
644
|
Promise.all([
|
|
639
645
|
fetch('/api/plugins').then(r => r.json()),
|
|
@@ -667,6 +673,7 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
667
673
|
const [watchSendTo, setWatchSendTo] = useState(initial.watch?.sendTo || '');
|
|
668
674
|
const [selectedPlugins, setSelectedPlugins] = useState<string[]>(initial.plugins || []);
|
|
669
675
|
const [recommendedTypes, setRecommendedTypes] = useState<string[]>([]);
|
|
676
|
+
const [savedTemplates, setSavedTemplates] = useState<{ id: string; name: string; icon: string; description?: string; config: any }[]>([]);
|
|
670
677
|
const [watchDebounce, setWatchDebounce] = useState(String(initial.watch?.targets?.[0]?.debounce ?? 10));
|
|
671
678
|
const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string; pattern?: string }[]>(
|
|
672
679
|
initial.watch?.targets || []
|
|
@@ -702,7 +709,53 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
702
709
|
setOutputs(p.outputs.join(', '));
|
|
703
710
|
setStepsText(p.steps.map(s => `${s.label}: ${s.prompt}`).join('\n'));
|
|
704
711
|
setRecommendedTypes(p.plugins || []);
|
|
705
|
-
setSelectedPlugins([]);
|
|
712
|
+
setSelectedPlugins(p.plugins || []);
|
|
713
|
+
if (p.persistentSession !== undefined) setPersistentSession(!!p.persistentSession);
|
|
714
|
+
if (p.skipPermissions !== undefined) setSkipPermissions(p.skipPermissions !== false);
|
|
715
|
+
if (p.requiresApproval !== undefined) setRequiresApproval(!!p.requiresApproval);
|
|
716
|
+
if (p.model) setAgentModel(p.model);
|
|
717
|
+
if (p.watch) {
|
|
718
|
+
setWatchEnabled(!!p.watch.enabled);
|
|
719
|
+
setWatchInterval(String(p.watch.interval || 60));
|
|
720
|
+
setWatchAction(p.watch.action || 'log');
|
|
721
|
+
setWatchPrompt(p.watch.prompt || '');
|
|
722
|
+
setWatchSendTo(p.watch.sendTo || '');
|
|
723
|
+
setWatchTargets(p.watch.targets || []);
|
|
724
|
+
setWatchDebounce(String(p.watch.targets?.[0]?.debounce ?? 10));
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
const applySavedTemplate = (t: { config: any }) => {
|
|
729
|
+
const c = t.config;
|
|
730
|
+
applyPreset({
|
|
731
|
+
label: c.label || '', icon: c.icon || '🤖', role: c.role || '',
|
|
732
|
+
backend: c.backend || 'cli', agentId: c.agentId, dependsOn: [],
|
|
733
|
+
workDir: c.workDir || './', outputs: c.outputs || [],
|
|
734
|
+
steps: c.steps || [], plugins: c.plugins,
|
|
735
|
+
persistentSession: c.persistentSession, skipPermissions: c.skipPermissions,
|
|
736
|
+
requiresApproval: c.requiresApproval, model: c.model,
|
|
737
|
+
watch: c.watch,
|
|
738
|
+
} as any);
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const handleImportFile = () => {
|
|
742
|
+
const input = document.createElement('input');
|
|
743
|
+
input.type = 'file';
|
|
744
|
+
input.accept = '.json';
|
|
745
|
+
input.onchange = async (e) => {
|
|
746
|
+
const file = (e.target as HTMLInputElement).files?.[0];
|
|
747
|
+
if (!file) return;
|
|
748
|
+
try {
|
|
749
|
+
const text = await file.text();
|
|
750
|
+
const data = JSON.parse(text);
|
|
751
|
+
// Support both raw config and template wrapper
|
|
752
|
+
const config = data.config || data;
|
|
753
|
+
applySavedTemplate({ config });
|
|
754
|
+
} catch {
|
|
755
|
+
alert('Invalid template file');
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
input.click();
|
|
706
759
|
};
|
|
707
760
|
|
|
708
761
|
const toggleDep = (id: string) => {
|
|
@@ -731,15 +784,16 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
731
784
|
</div>
|
|
732
785
|
|
|
733
786
|
<div className="flex flex-col gap-2.5">
|
|
734
|
-
{/* Preset
|
|
787
|
+
{/* Preset + saved templates (add mode only) */}
|
|
735
788
|
{mode === 'add' && (
|
|
736
789
|
<div className="flex flex-col gap-1">
|
|
737
|
-
<label className="text-[9px] text-gray-500 uppercase">
|
|
790
|
+
<label className="text-[9px] text-gray-500 uppercase">Presets</label>
|
|
738
791
|
<div className="flex gap-1 flex-wrap">
|
|
739
792
|
{PRESET_AGENTS.map((p, i) => (
|
|
740
793
|
<button key={i} onClick={() => applyPreset(p)}
|
|
741
|
-
|
|
742
|
-
{p.
|
|
794
|
+
title={p.primary ? 'Recommended for Primary smith (runs at project root, coordinates others)' : p.label}
|
|
795
|
+
className={`text-[9px] px-2 py-1 rounded border transition-colors ${label === p.label ? 'border-[#58a6ff] text-[#58a6ff] bg-[#58a6ff]/10' : p.primary ? 'border-[#f0883e]/40 text-[#f0883e] hover:border-[#f0883e]' : 'border-[#30363d] text-gray-400 hover:text-white'}`}>
|
|
796
|
+
{p.icon} {p.label}{p.primary ? ' ★' : ''}
|
|
743
797
|
</button>
|
|
744
798
|
))}
|
|
745
799
|
<button onClick={() => { setLabel(''); setIcon('🤖'); setRole(''); setStepsText(''); setOutputs(''); }}
|
|
@@ -747,6 +801,22 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
747
801
|
Custom
|
|
748
802
|
</button>
|
|
749
803
|
</div>
|
|
804
|
+
{savedTemplates.length > 0 && (<>
|
|
805
|
+
<label className="text-[9px] text-gray-500 uppercase mt-1">Saved Templates</label>
|
|
806
|
+
<div className="flex gap-1 flex-wrap">
|
|
807
|
+
{savedTemplates.map(t => (
|
|
808
|
+
<button key={t.id} onClick={() => applySavedTemplate(t)}
|
|
809
|
+
className={`text-[9px] px-2 py-1 rounded border transition-colors ${label === t.config?.label ? 'border-[#f0883e] text-[#f0883e] bg-[#f0883e]/10' : 'border-[#30363d] text-gray-400 hover:text-white'}`}
|
|
810
|
+
title={t.description || t.name}>
|
|
811
|
+
{t.icon} {t.name}
|
|
812
|
+
</button>
|
|
813
|
+
))}
|
|
814
|
+
</div>
|
|
815
|
+
</>)}
|
|
816
|
+
<button onClick={handleImportFile}
|
|
817
|
+
className="text-[9px] px-2 py-1 rounded border border-dashed border-[#30363d] text-gray-500 hover:text-white hover:border-gray-400 self-start mt-0.5">
|
|
818
|
+
📂 Import from file
|
|
819
|
+
</button>
|
|
750
820
|
</div>
|
|
751
821
|
)}
|
|
752
822
|
|
|
@@ -1176,6 +1246,26 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
1176
1246
|
</div>
|
|
1177
1247
|
|
|
1178
1248
|
<div className="flex justify-end gap-2 mt-4">
|
|
1249
|
+
{mode === 'edit' && (
|
|
1250
|
+
<button onClick={() => {
|
|
1251
|
+
const config = {
|
|
1252
|
+
label: label.trim(), icon: icon.trim() || '🤖', role: role.trim(),
|
|
1253
|
+
backend, agentId, workDir: workDirVal.trim() || './',
|
|
1254
|
+
outputs: outputs.split(',').map(s => s.trim()).filter(Boolean),
|
|
1255
|
+
steps: parseSteps(), plugins: selectedPlugins.length > 0 ? selectedPlugins : undefined,
|
|
1256
|
+
persistentSession: persistentSession || undefined, skipPermissions: persistentSession ? (skipPermissions ? undefined : false) : undefined,
|
|
1257
|
+
model: agentModel || undefined, requiresApproval: requiresApproval || undefined,
|
|
1258
|
+
watch: watchEnabled && watchTargets.length > 0 ? { enabled: true, interval: Math.max(10, parseInt(watchInterval) || 60), targets: watchTargets.map(t => ({ ...t, debounce: parseInt(watchDebounce) || 10 })), action: watchAction, prompt: watchPrompt || undefined, sendTo: watchSendTo || undefined } : undefined,
|
|
1259
|
+
};
|
|
1260
|
+
const blob = new Blob([JSON.stringify({ config, name: label.trim(), icon: icon.trim() || '🤖', exportedAt: Date.now() }, null, 2)], { type: 'application/json' });
|
|
1261
|
+
const url = URL.createObjectURL(blob);
|
|
1262
|
+
const a = document.createElement('a');
|
|
1263
|
+
a.href = url; a.download = `smith-${label.trim().toLowerCase().replace(/\s+/g, '-')}.json`; a.click();
|
|
1264
|
+
URL.revokeObjectURL(url);
|
|
1265
|
+
}} className="text-xs px-3 py-1.5 rounded border border-[#30363d] text-gray-400 hover:text-white mr-auto" title="Export config as file">
|
|
1266
|
+
📤 Export
|
|
1267
|
+
</button>
|
|
1268
|
+
)}
|
|
1179
1269
|
<button onClick={onCancel} className="text-xs px-3 py-1.5 rounded border border-[#30363d] text-gray-400 hover:text-white">Cancel</button>
|
|
1180
1270
|
<button disabled={!label.trim()} onClick={() => {
|
|
1181
1271
|
onConfirm({
|
|
@@ -2501,6 +2591,8 @@ interface AgentNodeData {
|
|
|
2501
2591
|
onShowInbox: () => void;
|
|
2502
2592
|
onOpenTerminal: () => void;
|
|
2503
2593
|
onSwitchSession: () => void;
|
|
2594
|
+
onSaveAsTemplate: () => void;
|
|
2595
|
+
mascotTheme: MascotTheme;
|
|
2504
2596
|
onMarkIdle?: () => void;
|
|
2505
2597
|
onMarkDone?: (notify: boolean) => void;
|
|
2506
2598
|
onMarkFailed?: (notify: boolean) => void;
|
|
@@ -2512,8 +2604,634 @@ interface AgentNodeData {
|
|
|
2512
2604
|
// PortalTerminal/NodeTerminal removed — xterm cannot render inside React Flow nodes
|
|
2513
2605
|
// and createPortal causes event routing issues. Using FloatingTerminal instead.
|
|
2514
2606
|
|
|
2607
|
+
// ─── Worker Mascot — SVG stick figure with pose-based animations ──────────────
|
|
2608
|
+
const MASCOT_STYLES = `
|
|
2609
|
+
@keyframes mascot-sleep {
|
|
2610
|
+
0%, 100% { transform: translateY(0) rotate(-3deg); opacity: 0.6; }
|
|
2611
|
+
50% { transform: translateY(-2px) rotate(3deg); opacity: 0.9; }
|
|
2612
|
+
}
|
|
2613
|
+
@keyframes mascot-work {
|
|
2614
|
+
0%, 100% { transform: translateY(0) rotate(0deg); }
|
|
2615
|
+
25% { transform: translateY(-2px) rotate(-6deg); }
|
|
2616
|
+
50% { transform: translateY(0) rotate(0deg); }
|
|
2617
|
+
75% { transform: translateY(-2px) rotate(6deg); }
|
|
2618
|
+
}
|
|
2619
|
+
@keyframes mascot-celebrate {
|
|
2620
|
+
0% { transform: translateY(0) scale(1); }
|
|
2621
|
+
12% { transform: translateY(-6px) scale(1.15) rotate(-10deg); }
|
|
2622
|
+
25% { transform: translateY(-3px) scale(1.1) rotate(0deg); }
|
|
2623
|
+
37% { transform: translateY(-6px) scale(1.15) rotate(10deg); }
|
|
2624
|
+
50% { transform: translateY(0) scale(1) rotate(0deg); }
|
|
2625
|
+
100% { transform: translateY(0) scale(1) rotate(0deg); }
|
|
2626
|
+
}
|
|
2627
|
+
@keyframes mascot-fall {
|
|
2628
|
+
0% { transform: translateY(0) rotate(0deg); }
|
|
2629
|
+
30% { transform: translateY(2px) rotate(-15deg); }
|
|
2630
|
+
60% { transform: translateY(4px) rotate(-90deg); }
|
|
2631
|
+
100% { transform: translateY(4px) rotate(-90deg); opacity: 0.6; }
|
|
2632
|
+
}
|
|
2633
|
+
@keyframes mascot-idle {
|
|
2634
|
+
0%, 100% { transform: translateY(0); }
|
|
2635
|
+
50% { transform: translateY(-1px); }
|
|
2636
|
+
}
|
|
2637
|
+
@keyframes mascot-blink { 0%, 95%, 100% { opacity: 1; } 97% { opacity: 0.3; } }
|
|
2638
|
+
@keyframes stick-arm-hammer {
|
|
2639
|
+
0%, 100% { transform: rotate(-40deg); }
|
|
2640
|
+
50% { transform: rotate(20deg); }
|
|
2641
|
+
}
|
|
2642
|
+
@keyframes stick-arm-wave {
|
|
2643
|
+
0%, 100% { transform: rotate(-120deg); }
|
|
2644
|
+
50% { transform: rotate(-150deg); }
|
|
2645
|
+
}
|
|
2646
|
+
@keyframes stick-leg-walk-l {
|
|
2647
|
+
0%, 100% { transform: rotate(-10deg); }
|
|
2648
|
+
50% { transform: rotate(10deg); }
|
|
2649
|
+
}
|
|
2650
|
+
@keyframes stick-leg-walk-r {
|
|
2651
|
+
0%, 100% { transform: rotate(10deg); }
|
|
2652
|
+
50% { transform: rotate(-10deg); }
|
|
2653
|
+
}
|
|
2654
|
+
@keyframes stick-zzz {
|
|
2655
|
+
0% { opacity: 0; transform: translate(0, 0) scale(0.5); }
|
|
2656
|
+
50% { opacity: 1; transform: translate(4px, -6px) scale(1); }
|
|
2657
|
+
100% { opacity: 0; transform: translate(8px, -12px) scale(1.2); }
|
|
2658
|
+
}
|
|
2659
|
+
@keyframes stick-spark {
|
|
2660
|
+
0%, 100% { opacity: 0; }
|
|
2661
|
+
50% { opacity: 1; }
|
|
2662
|
+
}
|
|
2663
|
+
@keyframes stick-spark-burst {
|
|
2664
|
+
0% { opacity: 0; transform: scale(0.5); }
|
|
2665
|
+
30% { opacity: 1; transform: scale(1.2); }
|
|
2666
|
+
70% { opacity: 1; transform: scale(1); }
|
|
2667
|
+
100% { opacity: 0; transform: scale(0.8); }
|
|
2668
|
+
}
|
|
2669
|
+
`;
|
|
2670
|
+
type MascotPose = 'idle' | 'work' | 'done' | 'fail' | 'sleep' | 'wake';
|
|
2671
|
+
export type MascotTheme = 'off' | 'stick' | 'cat' | 'dog' | 'pig' | 'emoji';
|
|
2672
|
+
|
|
2673
|
+
function StickCat({ pose, color, accentColor }: { pose: MascotPose; color: string; accentColor: string }) {
|
|
2674
|
+
const strokeProps = { stroke: color, strokeWidth: 1.5, strokeLinecap: 'round' as const, fill: 'none' };
|
|
2675
|
+
const body = (tailAnim: string) => (
|
|
2676
|
+
<>
|
|
2677
|
+
{/* head */}
|
|
2678
|
+
<circle cx="10" cy="18" r="5" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2679
|
+
{/* ears */}
|
|
2680
|
+
<path d="M 6 15 L 7 11 L 10 14 Z" fill={color} />
|
|
2681
|
+
<path d="M 14 15 L 13 11 L 10 14 Z" fill={color} />
|
|
2682
|
+
{/* eyes */}
|
|
2683
|
+
<circle cx="8" cy="18" r="0.8" fill={accentColor} />
|
|
2684
|
+
<circle cx="12" cy="18" r="0.8" fill={accentColor} />
|
|
2685
|
+
{/* nose */}
|
|
2686
|
+
<path d="M 9.5 19.5 L 10 20 L 10.5 19.5" stroke={accentColor} strokeWidth="0.8" fill="none" strokeLinecap="round" />
|
|
2687
|
+
{/* whiskers */}
|
|
2688
|
+
<line x1="5" y1="19" x2="2" y2="18" stroke={color} strokeWidth="0.6" />
|
|
2689
|
+
<line x1="5" y1="20" x2="2" y2="20" stroke={color} strokeWidth="0.6" />
|
|
2690
|
+
<line x1="15" y1="19" x2="18" y2="18" stroke={color} strokeWidth="0.6" />
|
|
2691
|
+
<line x1="15" y1="20" x2="18" y2="20" stroke={color} strokeWidth="0.6" />
|
|
2692
|
+
{/* body — oval */}
|
|
2693
|
+
<ellipse cx="18" cy="26" rx="8" ry="5" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2694
|
+
{/* tail */}
|
|
2695
|
+
<g style={{ transformOrigin: '26px 26px', animation: tailAnim }}>
|
|
2696
|
+
<path d="M 26 26 Q 30 22 28 18" {...strokeProps} />
|
|
2697
|
+
</g>
|
|
2698
|
+
{/* legs */}
|
|
2699
|
+
<line x1="13" y1="30" x2="13" y2="36" {...strokeProps} />
|
|
2700
|
+
<line x1="23" y1="30" x2="23" y2="36" {...strokeProps} />
|
|
2701
|
+
<line x1="16" y1="31" x2="16" y2="36" {...strokeProps} />
|
|
2702
|
+
<line x1="20" y1="31" x2="20" y2="36" {...strokeProps} />
|
|
2703
|
+
</>
|
|
2704
|
+
);
|
|
2705
|
+
|
|
2706
|
+
if (pose === 'sleep') {
|
|
2707
|
+
return (
|
|
2708
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2709
|
+
{/* curled up cat — circle with tail */}
|
|
2710
|
+
<circle cx="16" cy="30" r="8" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2711
|
+
<circle cx="10" cy="28" r="3" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2712
|
+
<line x1="9" y1="27" x2="9" y2="29" stroke={color} strokeWidth="0.8" />
|
|
2713
|
+
<line x1="11" y1="27" x2="11" y2="29" stroke={color} strokeWidth="0.8" />
|
|
2714
|
+
<path d="M 23 32 Q 28 32 26 26" {...strokeProps} />
|
|
2715
|
+
{/* zzz */}
|
|
2716
|
+
<text x="20" y="20" fill={accentColor} fontSize="6" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite' }}>z</text>
|
|
2717
|
+
<text x="24" y="14" fill={accentColor} fontSize="4" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite 0.7s' }}>z</text>
|
|
2718
|
+
</svg>
|
|
2719
|
+
);
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
if (pose === 'fail') {
|
|
2723
|
+
return (
|
|
2724
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2725
|
+
{/* belly up */}
|
|
2726
|
+
<ellipse cx="18" cy="26" rx="8" ry="5" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2727
|
+
<circle cx="10" cy="24" r="4" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2728
|
+
<line x1="8" y1="23" x2="9" y2="24" stroke={accentColor} strokeWidth="0.8" />
|
|
2729
|
+
<line x1="9" y1="23" x2="8" y2="24" stroke={accentColor} strokeWidth="0.8" />
|
|
2730
|
+
<line x1="11" y1="23" x2="12" y2="24" stroke={accentColor} strokeWidth="0.8" />
|
|
2731
|
+
<line x1="12" y1="23" x2="11" y2="24" stroke={accentColor} strokeWidth="0.8" />
|
|
2732
|
+
{/* legs up */}
|
|
2733
|
+
<line x1="14" y1="22" x2="14" y2="16" {...strokeProps} />
|
|
2734
|
+
<line x1="18" y1="22" x2="18" y2="15" {...strokeProps} />
|
|
2735
|
+
<line x1="22" y1="22" x2="22" y2="16" {...strokeProps} />
|
|
2736
|
+
</svg>
|
|
2737
|
+
);
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
if (pose === 'done') {
|
|
2741
|
+
return (
|
|
2742
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2743
|
+
{/* jumping — body elevated */}
|
|
2744
|
+
<g style={{ transform: 'translateY(-2px)' }}>
|
|
2745
|
+
{body('none')}
|
|
2746
|
+
</g>
|
|
2747
|
+
<text x="2" y="8" fill="#ffd700" fontSize="6" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards' }}>✦</text>
|
|
2748
|
+
<text x="26" y="10" fill="#ffd700" fontSize="8" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.3s' }}>✦</text>
|
|
2749
|
+
</svg>
|
|
2750
|
+
);
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
if (pose === 'work') {
|
|
2754
|
+
return (
|
|
2755
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2756
|
+
{body('stick-arm-hammer 0.4s ease-in-out infinite')}
|
|
2757
|
+
</svg>
|
|
2758
|
+
);
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
if (pose === 'wake') {
|
|
2762
|
+
return (
|
|
2763
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2764
|
+
{/* stretching — elongated body */}
|
|
2765
|
+
<circle cx="8" cy="22" r="4" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2766
|
+
<path d="M 4 19 L 5 16 L 8 18 Z" fill={color} />
|
|
2767
|
+
<path d="M 12 19 L 11 16 L 8 18 Z" fill={color} />
|
|
2768
|
+
<circle cx="6.5" cy="22" r="0.6" fill={accentColor} />
|
|
2769
|
+
<circle cx="9.5" cy="22" r="0.6" fill={accentColor} />
|
|
2770
|
+
<ellipse cx="20" cy="28" rx="10" ry="4" stroke={color} strokeWidth="1.5" fill="none" />
|
|
2771
|
+
<line x1="14" y1="32" x2="14" y2="38" {...strokeProps} />
|
|
2772
|
+
<line x1="26" y1="32" x2="26" y2="38" {...strokeProps} />
|
|
2773
|
+
<path d="M 30 28 Q 32 24 30 20" {...strokeProps} />
|
|
2774
|
+
</svg>
|
|
2775
|
+
);
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
// idle — tail swaying
|
|
2779
|
+
return (
|
|
2780
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2781
|
+
{body('stick-arm-wave 2s ease-in-out infinite')}
|
|
2782
|
+
</svg>
|
|
2783
|
+
);
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
function StickDog({ pose, color, accentColor }: { pose: MascotPose; color: string; accentColor: string }) {
|
|
2787
|
+
// Side-profile dog — elongated snout forward, triangular perked ear, visible tail
|
|
2788
|
+
// Designed to read clearly at small sizes with distinct dog silhouette
|
|
2789
|
+
|
|
2790
|
+
if (pose === 'sleep') {
|
|
2791
|
+
return (
|
|
2792
|
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
2793
|
+
{/* body lying down */}
|
|
2794
|
+
<ellipse cx="22" cy="32" rx="12" ry="4" stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.15" />
|
|
2795
|
+
{/* head resting on paws — side profile */}
|
|
2796
|
+
<path d="M 10 32 Q 6 30 4 32 Q 2 33 3 35 L 10 35 Z" stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.2" strokeLinejoin="round" />
|
|
2797
|
+
{/* long snout */}
|
|
2798
|
+
<path d="M 3 34 L 1 35 L 3 36" stroke={color} strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
2799
|
+
{/* floppy ear */}
|
|
2800
|
+
<path d="M 8 30 Q 6 33 9 34" stroke={color} strokeWidth="2" fill={color} fillOpacity="0.35" strokeLinecap="round" />
|
|
2801
|
+
{/* closed eye */}
|
|
2802
|
+
<path d="M 6 33 Q 7 32.5 8 33" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
|
|
2803
|
+
{/* nose */}
|
|
2804
|
+
<ellipse cx="1.5" cy="35" rx="0.9" ry="0.7" fill={color} />
|
|
2805
|
+
{/* curled tail */}
|
|
2806
|
+
<path d="M 33 32 Q 38 30 36 26 Q 35 25 36 24" stroke={color} strokeWidth="2" fill="none" strokeLinecap="round" />
|
|
2807
|
+
<text x="18" y="18" fill={accentColor} fontSize="7" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite' }}>z</text>
|
|
2808
|
+
<text x="24" y="12" fill={accentColor} fontSize="5" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite 0.7s' }}>z</text>
|
|
2809
|
+
</svg>
|
|
2810
|
+
);
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
if (pose === 'fail') {
|
|
2814
|
+
return (
|
|
2815
|
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
2816
|
+
{/* belly up */}
|
|
2817
|
+
<ellipse cx="22" cy="32" rx="12" ry="4" stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.15" />
|
|
2818
|
+
{/* head upside down */}
|
|
2819
|
+
<path d="M 10 32 Q 6 34 4 32 Q 2 31 3 29 L 10 29 Z" stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.2" strokeLinejoin="round" />
|
|
2820
|
+
<path d="M 3 30 L 1 29 L 3 28" stroke={color} strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
2821
|
+
{/* X eyes */}
|
|
2822
|
+
<line x1="5" y1="30" x2="6.5" y2="31.5" stroke={accentColor} strokeWidth="1.2" strokeLinecap="round" />
|
|
2823
|
+
<line x1="6.5" y1="30" x2="5" y2="31.5" stroke={accentColor} strokeWidth="1.2" strokeLinecap="round" />
|
|
2824
|
+
{/* tongue hanging out sideways */}
|
|
2825
|
+
<path d="M 2 30 Q 1 27 2 25" stroke="#ff6b9d" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
|
2826
|
+
{/* all 4 legs sticking up */}
|
|
2827
|
+
<line x1="14" y1="28" x2="13" y2="20" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2828
|
+
<line x1="18" y1="28" x2="18" y2="18" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2829
|
+
<line x1="26" y1="28" x2="26" y2="18" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2830
|
+
<line x1="30" y1="28" x2="31" y2="20" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2831
|
+
{/* limp tail */}
|
|
2832
|
+
<path d="M 33 32 L 37 34" stroke={color} strokeWidth="2" fill="none" strokeLinecap="round" />
|
|
2833
|
+
</svg>
|
|
2834
|
+
);
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
// Standing side-profile dog
|
|
2838
|
+
const standingDog = (tailAnim: string, bounce: string = '') => (
|
|
2839
|
+
<g style={bounce ? { transform: bounce } : {}}>
|
|
2840
|
+
{/* body — side profile, clearly elongated horizontal */}
|
|
2841
|
+
<path d="M 11 22 L 28 22 Q 32 22 32 26 L 32 30 Q 32 32 30 32 L 9 32 Q 7 32 7 30 L 7 27 Q 7 23 11 22 Z"
|
|
2842
|
+
stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.15" strokeLinejoin="round" />
|
|
2843
|
+
{/* head — side profile with long snout pointing LEFT */}
|
|
2844
|
+
<path d="M 11 22 Q 8 22 6 20 Q 4 20 2 22 Q 0 23 1 25 Q 1 27 3 27 L 8 27 Q 10 27 11 25 Z"
|
|
2845
|
+
stroke={color} strokeWidth="1.8" fill={color} fillOpacity="0.2" strokeLinejoin="round" />
|
|
2846
|
+
{/* triangular perked ear (pointing up-back) */}
|
|
2847
|
+
<path d="M 8 22 L 10 15 L 12 20 Z" stroke={color} strokeWidth="1.5" fill={color} fillOpacity="0.4" strokeLinejoin="round" />
|
|
2848
|
+
{/* big black nose at tip */}
|
|
2849
|
+
<ellipse cx="1" cy="24" rx="1.3" ry="1" fill={color} />
|
|
2850
|
+
{/* eye */}
|
|
2851
|
+
<circle cx="7" cy="23" r="1" fill={accentColor} />
|
|
2852
|
+
<circle cx="6.7" cy="22.7" r="0.3" fill="#fff" />
|
|
2853
|
+
{/* mouth line */}
|
|
2854
|
+
<path d="M 1 25.5 Q 3 27 6 26" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
|
|
2855
|
+
{/* tongue hanging out */}
|
|
2856
|
+
<path d="M 2.5 26.5 Q 3 28.5 2 29" stroke="#ff6b9d" strokeWidth="1.2" fill="#ff6b9d" strokeLinecap="round" />
|
|
2857
|
+
{/* curled tail (pointing up-right) — wags */}
|
|
2858
|
+
<g style={{ transformOrigin: '32px 26px', animation: tailAnim }}>
|
|
2859
|
+
<path d="M 32 26 Q 37 24 36 19 Q 36 17 38 17" stroke={color} strokeWidth="2.2" fill="none" strokeLinecap="round" />
|
|
2860
|
+
</g>
|
|
2861
|
+
{/* 4 legs — visible in side profile (2 front + 2 back, front pair visible) */}
|
|
2862
|
+
<line x1="10" y1="32" x2="10" y2="38" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2863
|
+
<line x1="14" y1="32" x2="14" y2="38" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2864
|
+
<line x1="26" y1="32" x2="26" y2="38" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2865
|
+
<line x1="30" y1="32" x2="30" y2="38" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
|
|
2866
|
+
{/* paws */}
|
|
2867
|
+
<rect x="8.5" y="37.5" width="3" height="1.5" fill={color} rx="0.5" />
|
|
2868
|
+
<rect x="12.5" y="37.5" width="3" height="1.5" fill={color} rx="0.5" />
|
|
2869
|
+
<rect x="24.5" y="37.5" width="3" height="1.5" fill={color} rx="0.5" />
|
|
2870
|
+
<rect x="28.5" y="37.5" width="3" height="1.5" fill={color} rx="0.5" />
|
|
2871
|
+
{/* collar */}
|
|
2872
|
+
<line x1="9" y1="27" x2="12" y2="27" stroke={accentColor} strokeWidth="1.5" strokeLinecap="round" />
|
|
2873
|
+
<circle cx="10.5" cy="28" r="0.9" fill={accentColor} />
|
|
2874
|
+
</g>
|
|
2875
|
+
);
|
|
2876
|
+
|
|
2877
|
+
if (pose === 'done') {
|
|
2878
|
+
return (
|
|
2879
|
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
2880
|
+
{standingDog('stick-arm-wave 0.3s ease-in-out infinite', 'translateY(-3px)')}
|
|
2881
|
+
<text x="2" y="10" fill="#ffd700" fontSize="7" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards' }}>✦</text>
|
|
2882
|
+
<text x="34" y="12" fill="#ffd700" fontSize="9" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.3s' }}>✦</text>
|
|
2883
|
+
<text x="18" y="6" fill="#ffd700" fontSize="6" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.5s' }}>✦</text>
|
|
2884
|
+
</svg>
|
|
2885
|
+
);
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
if (pose === 'work') {
|
|
2889
|
+
return (
|
|
2890
|
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
2891
|
+
{standingDog('stick-arm-wave 0.25s ease-in-out infinite')}
|
|
2892
|
+
{/* bone in mouth */}
|
|
2893
|
+
<g transform="translate(-6, 0)">
|
|
2894
|
+
<circle cx="0" cy="25" r="1.5" fill={accentColor} stroke={color} strokeWidth="0.6" />
|
|
2895
|
+
<rect x="0" y="24.2" width="5" height="1.6" fill={accentColor} stroke={color} strokeWidth="0.6" rx="0.4" />
|
|
2896
|
+
<circle cx="5" cy="25" r="1.5" fill={accentColor} stroke={color} strokeWidth="0.6" />
|
|
2897
|
+
</g>
|
|
2898
|
+
</svg>
|
|
2899
|
+
);
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
if (pose === 'wake') {
|
|
2903
|
+
return (
|
|
2904
|
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
2905
|
+
{standingDog('stick-arm-wave 1.5s ease-in-out infinite')}
|
|
2906
|
+
{/* yawn — open mouth replaces tongue */}
|
|
2907
|
+
<ellipse cx="2" cy="26" rx="1.3" ry="1.8" fill={color} opacity="0.5" />
|
|
2908
|
+
</svg>
|
|
2909
|
+
);
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// idle — standing, happy tail wag
|
|
2913
|
+
return (
|
|
2914
|
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
2915
|
+
{standingDog('stick-arm-wave 0.6s ease-in-out infinite')}
|
|
2916
|
+
</svg>
|
|
2917
|
+
);
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
function StickPig({ pose, color, accentColor }: { pose: MascotPose; color: string; accentColor: string }) {
|
|
2921
|
+
const pink = '#ff9ecb';
|
|
2922
|
+
const pinkFill = '#ff9ecb';
|
|
2923
|
+
|
|
2924
|
+
if (pose === 'sleep') {
|
|
2925
|
+
return (
|
|
2926
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2927
|
+
<ellipse cx="16" cy="30" rx="12" ry="7" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.25" />
|
|
2928
|
+
<circle cx="8" cy="28" r="4" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.3" />
|
|
2929
|
+
{/* pig snout disc */}
|
|
2930
|
+
<ellipse cx="4.5" cy="29" rx="2.5" ry="1.8" stroke={pink} strokeWidth="1.5" fill={pinkFill} fillOpacity="0.4" />
|
|
2931
|
+
<circle cx="4" cy="29" r="0.5" fill={color} />
|
|
2932
|
+
<circle cx="5" cy="29" r="0.5" fill={color} />
|
|
2933
|
+
{/* pointy triangular ears */}
|
|
2934
|
+
<path d="M 5 24 L 6 22 L 8 25 Z" fill={pinkFill} stroke={pink} strokeWidth="1" />
|
|
2935
|
+
<path d="M 9 24 L 10 22 L 11 25 Z" fill={pinkFill} stroke={pink} strokeWidth="1" />
|
|
2936
|
+
{/* closed eyes */}
|
|
2937
|
+
<path d="M 7 27 Q 7.5 26.5 8 27" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
|
|
2938
|
+
<path d="M 9 27 Q 9.5 26.5 10 27" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
|
|
2939
|
+
{/* curly tail */}
|
|
2940
|
+
<path d="M 28 28 Q 30 26 28 24 Q 26 24 28 22" stroke={pink} strokeWidth="2" fill="none" strokeLinecap="round" />
|
|
2941
|
+
<text x="16" y="16" fill={accentColor} fontSize="7" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite' }}>z</text>
|
|
2942
|
+
<text x="21" y="10" fill={accentColor} fontSize="5" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite 0.7s' }}>z</text>
|
|
2943
|
+
</svg>
|
|
2944
|
+
);
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
if (pose === 'fail') {
|
|
2948
|
+
return (
|
|
2949
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
2950
|
+
<ellipse cx="18" cy="29" rx="11" ry="5" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.25" />
|
|
2951
|
+
<circle cx="8" cy="27" r="4.5" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.3" />
|
|
2952
|
+
<ellipse cx="7" cy="23" rx="2.5" ry="2" stroke={pink} strokeWidth="1.5" fill={pinkFill} fillOpacity="0.5" />
|
|
2953
|
+
<circle cx="6.3" cy="23" r="0.5" fill={color} />
|
|
2954
|
+
<circle cx="7.7" cy="23" r="0.5" fill={color} />
|
|
2955
|
+
<path d="M 4 28 Q 0 30 2 34" fill={pinkFill} stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
|
|
2956
|
+
<path d="M 12 28 Q 16 30 14 34" fill={pinkFill} stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
|
|
2957
|
+
<line x1="6" y1="26" x2="7.5" y2="27.5" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
|
|
2958
|
+
<line x1="7.5" y1="26" x2="6" y2="27.5" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
|
|
2959
|
+
<line x1="9" y1="26" x2="10.5" y2="27.5" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
|
|
2960
|
+
<line x1="10.5" y1="26" x2="9" y2="27.5" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
|
|
2961
|
+
<line x1="14" y1="25" x2="13" y2="17" stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
|
|
2962
|
+
<line x1="18" y1="25" x2="18" y2="16" stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
|
|
2963
|
+
<line x1="22" y1="25" x2="22" y2="17" stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
|
|
2964
|
+
<line x1="26" y1="25" x2="27" y2="18" stroke={pink} strokeWidth="1.8" strokeLinecap="round" />
|
|
2965
|
+
</svg>
|
|
2966
|
+
);
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
const pigBody = (tailAnim: string, bounce: string = '') => (
|
|
2970
|
+
<g style={bounce ? { transform: bounce } : {}}>
|
|
2971
|
+
{/* round pig body */}
|
|
2972
|
+
<ellipse cx="18" cy="27" rx="11" ry="6.5" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.25" />
|
|
2973
|
+
{/* round head */}
|
|
2974
|
+
<circle cx="9" cy="18" r="6" stroke={pink} strokeWidth="1.8" fill={pinkFill} fillOpacity="0.3" />
|
|
2975
|
+
{/* pig snout — flat disc with nostrils */}
|
|
2976
|
+
<ellipse cx="5" cy="20" rx="3" ry="2.2" stroke={pink} strokeWidth="1.5" fill={pinkFill} fillOpacity="0.5" />
|
|
2977
|
+
<circle cx="4" cy="20" r="0.6" fill={color} />
|
|
2978
|
+
<circle cx="6" cy="20" r="0.6" fill={color} />
|
|
2979
|
+
{/* triangular pointed ears */}
|
|
2980
|
+
<path d="M 6 13 L 7 10 L 9 14 Z" fill={pinkFill} stroke={pink} strokeWidth="1.2" strokeLinejoin="round" />
|
|
2981
|
+
<path d="M 11 13 L 12 10 L 14 14 Z" fill={pinkFill} stroke={pink} strokeWidth="1.2" strokeLinejoin="round" />
|
|
2982
|
+
{/* eyes */}
|
|
2983
|
+
<circle cx="8" cy="17" r="1" fill={color} />
|
|
2984
|
+
<circle cx="12" cy="17" r="1" fill={color} />
|
|
2985
|
+
<circle cx="7.7" cy="16.7" r="0.3" fill="#fff" />
|
|
2986
|
+
<circle cx="11.7" cy="16.7" r="0.3" fill="#fff" />
|
|
2987
|
+
{/* smile */}
|
|
2988
|
+
<path d="M 7 22 Q 9 23 11 22" stroke={color} strokeWidth="0.8" fill="none" strokeLinecap="round" />
|
|
2989
|
+
{/* curly tail — wagging */}
|
|
2990
|
+
<g style={{ transformOrigin: '29px 25px', animation: tailAnim }}>
|
|
2991
|
+
<path d="M 29 25 Q 32 23 30 21 Q 28 21 30 19 Q 31 18 32 19" stroke={pink} strokeWidth="2" fill="none" strokeLinecap="round" />
|
|
2992
|
+
</g>
|
|
2993
|
+
{/* trotter legs */}
|
|
2994
|
+
<line x1="12" y1="32" x2="12" y2="38" stroke={pink} strokeWidth="2" strokeLinecap="round" />
|
|
2995
|
+
<line x1="16" y1="33" x2="16" y2="38" stroke={pink} strokeWidth="2" strokeLinecap="round" />
|
|
2996
|
+
<line x1="20" y1="33" x2="20" y2="38" stroke={pink} strokeWidth="2" strokeLinecap="round" />
|
|
2997
|
+
<line x1="24" y1="32" x2="24" y2="38" stroke={pink} strokeWidth="2" strokeLinecap="round" />
|
|
2998
|
+
{/* hooves */}
|
|
2999
|
+
<rect x="10.5" y="37.5" width="3" height="1.8" fill={color} rx="0.3" />
|
|
3000
|
+
<rect x="14.5" y="37.5" width="3" height="1.8" fill={color} rx="0.3" />
|
|
3001
|
+
<rect x="18.5" y="37.5" width="3" height="1.8" fill={color} rx="0.3" />
|
|
3002
|
+
<rect x="22.5" y="37.5" width="3" height="1.8" fill={color} rx="0.3" />
|
|
3003
|
+
</g>
|
|
3004
|
+
);
|
|
3005
|
+
|
|
3006
|
+
if (pose === 'done') {
|
|
3007
|
+
return (
|
|
3008
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3009
|
+
{pigBody('stick-arm-wave 0.4s ease-in-out infinite', 'translateY(-2px)')}
|
|
3010
|
+
<text x="2" y="8" fill="#ffd700" fontSize="6" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards' }}>✦</text>
|
|
3011
|
+
<text x="26" y="10" fill="#ffd700" fontSize="8" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.3s' }}>✦</text>
|
|
3012
|
+
</svg>
|
|
3013
|
+
);
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
if (pose === 'work') {
|
|
3017
|
+
return (
|
|
3018
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3019
|
+
{pigBody('stick-arm-wave 0.3s ease-in-out infinite')}
|
|
3020
|
+
</svg>
|
|
3021
|
+
);
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
if (pose === 'wake') {
|
|
3025
|
+
return (
|
|
3026
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3027
|
+
{pigBody('stick-arm-wave 1.5s ease-in-out infinite')}
|
|
3028
|
+
<ellipse cx="9" cy="20" rx="1.3" ry="1.5" fill={color} opacity="0.4" />
|
|
3029
|
+
</svg>
|
|
3030
|
+
);
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
return (
|
|
3034
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3035
|
+
{pigBody('stick-arm-wave 0.7s ease-in-out infinite')}
|
|
3036
|
+
</svg>
|
|
3037
|
+
);
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
function EmojiMascot({ pose, seed }: { pose: MascotPose; seed: number }) {
|
|
3041
|
+
const characters = ['🦊', '🐱', '🐼', '🦉', '🐸', '🦝', '🐙', '🦖', '🐰', '🦄', '🐺', '🧙♂️', '🧝♀️', '🦸♂️', '🥷', '🐲'];
|
|
3042
|
+
const character = characters[seed % characters.length];
|
|
3043
|
+
let display = character;
|
|
3044
|
+
if (pose === 'sleep') display = ['😴', '💤', '🌙', '💤'][Math.floor(Date.now() / 1200) % 4];
|
|
3045
|
+
else if (pose === 'work') { const tools = ['🔨', '⚙️', '🛠️', '⚡']; const tick = Math.floor(Date.now() / 400); display = tick % 3 === 0 ? character : tools[tick % tools.length]; }
|
|
3046
|
+
else if (pose === 'done') display = ['🎉', '🎊', '🥳', '🌟'][Math.floor(Date.now() / 600) % 4];
|
|
3047
|
+
else if (pose === 'fail') display = ['😵', '💫', '🤕', '😿'][seed % 4];
|
|
3048
|
+
else if (pose === 'wake') display = ['🥱', '☕', '🌅'][Math.floor(Date.now() / 1000) % 3];
|
|
3049
|
+
return <div style={{ fontSize: '24px', lineHeight: 1 }}>{display}</div>;
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
function StickFigure({ pose, color, accentColor }: { pose: MascotPose; color: string; accentColor: string }) {
|
|
3053
|
+
// viewBox 32×40: head at (16,8), body (16,12)→(16,26), arms from (16,14), legs from (16,26)
|
|
3054
|
+
const strokeProps = { stroke: color, strokeWidth: 2, strokeLinecap: 'round' as const, fill: 'none' };
|
|
3055
|
+
|
|
3056
|
+
if (pose === 'sleep') {
|
|
3057
|
+
// Lying down, sleeping
|
|
3058
|
+
return (
|
|
3059
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3060
|
+
{/* body horizontal */}
|
|
3061
|
+
<circle cx="8" cy="30" r="3" {...strokeProps} fill={color} />
|
|
3062
|
+
<line x1="11" y1="30" x2="26" y2="30" {...strokeProps} />
|
|
3063
|
+
<line x1="14" y1="30" x2="18" y2="26" {...strokeProps} />
|
|
3064
|
+
<line x1="20" y1="30" x2="24" y2="34" {...strokeProps} />
|
|
3065
|
+
{/* zzz */}
|
|
3066
|
+
<text x="18" y="14" fill={accentColor} fontSize="8" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite' }}>z</text>
|
|
3067
|
+
<text x="22" y="10" fill={accentColor} fontSize="6" fontWeight="bold" style={{ animation: 'stick-zzz 2s ease-out infinite 0.7s' }}>z</text>
|
|
3068
|
+
</svg>
|
|
3069
|
+
);
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
if (pose === 'wake') {
|
|
3073
|
+
// Stretching — arms up
|
|
3074
|
+
return (
|
|
3075
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3076
|
+
<circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
|
|
3077
|
+
<line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
|
|
3078
|
+
<line x1="16" y1="14" x2="10" y2="6" {...strokeProps} />
|
|
3079
|
+
<line x1="16" y1="14" x2="22" y2="6" {...strokeProps} />
|
|
3080
|
+
<line x1="16" y1="26" x2="12" y2="34" {...strokeProps} />
|
|
3081
|
+
<line x1="16" y1="26" x2="20" y2="34" {...strokeProps} />
|
|
3082
|
+
{/* ☼ */}
|
|
3083
|
+
<circle cx="26" cy="6" r="2" fill={accentColor} opacity="0.8" />
|
|
3084
|
+
</svg>
|
|
3085
|
+
);
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
if (pose === 'done') {
|
|
3089
|
+
// Victory pose — both arms up, legs apart
|
|
3090
|
+
return (
|
|
3091
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3092
|
+
<circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
|
|
3093
|
+
{/* smile */}
|
|
3094
|
+
<path d="M 14 8 Q 16 10 18 8" stroke={accentColor} strokeWidth="1" fill="none" strokeLinecap="round" />
|
|
3095
|
+
<line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
|
|
3096
|
+
<line x1="16" y1="14" x2="8" y2="4" {...strokeProps} />
|
|
3097
|
+
<line x1="16" y1="14" x2="24" y2="4" {...strokeProps} />
|
|
3098
|
+
<line x1="16" y1="26" x2="10" y2="36" {...strokeProps} />
|
|
3099
|
+
<line x1="16" y1="26" x2="22" y2="36" {...strokeProps} />
|
|
3100
|
+
{/* sparkles */}
|
|
3101
|
+
<text x="4" y="4" fill="#ffd700" fontSize="6" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards' }}>✦</text>
|
|
3102
|
+
<text x="26" y="6" fill="#ffd700" fontSize="8" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.3s' }}>✦</text>
|
|
3103
|
+
<text x="2" y="20" fill="#ffd700" fontSize="5" style={{ animation: 'stick-spark-burst 1.2s ease-out forwards 0.5s' }}>✦</text>
|
|
3104
|
+
</svg>
|
|
3105
|
+
);
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
if (pose === 'fail') {
|
|
3109
|
+
// Fallen down — lying on back, X eyes (handled via external rotate)
|
|
3110
|
+
return (
|
|
3111
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3112
|
+
<circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
|
|
3113
|
+
{/* X eyes */}
|
|
3114
|
+
<line x1="14" y1="6" x2="15" y2="7" stroke={accentColor} strokeWidth="1" strokeLinecap="round" />
|
|
3115
|
+
<line x1="15" y1="6" x2="14" y2="7" stroke={accentColor} strokeWidth="1" strokeLinecap="round" />
|
|
3116
|
+
<line x1="17" y1="6" x2="18" y2="7" stroke={accentColor} strokeWidth="1" strokeLinecap="round" />
|
|
3117
|
+
<line x1="18" y1="6" x2="17" y2="7" stroke={accentColor} strokeWidth="1" strokeLinecap="round" />
|
|
3118
|
+
<line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
|
|
3119
|
+
<line x1="16" y1="14" x2="8" y2="18" {...strokeProps} />
|
|
3120
|
+
<line x1="16" y1="14" x2="24" y2="18" {...strokeProps} />
|
|
3121
|
+
<line x1="16" y1="26" x2="10" y2="34" {...strokeProps} />
|
|
3122
|
+
<line x1="16" y1="26" x2="22" y2="34" {...strokeProps} />
|
|
3123
|
+
</svg>
|
|
3124
|
+
);
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
if (pose === 'work') {
|
|
3128
|
+
// Hammering — left arm stable, right arm swinging with hammer
|
|
3129
|
+
return (
|
|
3130
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3131
|
+
<circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
|
|
3132
|
+
<line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
|
|
3133
|
+
{/* left arm holding nail */}
|
|
3134
|
+
<line x1="16" y1="14" x2="10" y2="20" {...strokeProps} />
|
|
3135
|
+
{/* right arm swinging hammer */}
|
|
3136
|
+
<g style={{ transformOrigin: '16px 14px', animation: 'stick-arm-hammer 0.5s ease-in-out infinite' }}>
|
|
3137
|
+
<line x1="16" y1="14" x2="24" y2="14" {...strokeProps} />
|
|
3138
|
+
{/* hammer */}
|
|
3139
|
+
<rect x="24" y="11" width="5" height="6" fill={accentColor} stroke={color} strokeWidth="1" rx="1" />
|
|
3140
|
+
</g>
|
|
3141
|
+
{/* legs walking */}
|
|
3142
|
+
<g style={{ transformOrigin: '16px 26px', animation: 'stick-leg-walk-l 0.5s ease-in-out infinite' }}>
|
|
3143
|
+
<line x1="16" y1="26" x2="12" y2="36" {...strokeProps} />
|
|
3144
|
+
</g>
|
|
3145
|
+
<g style={{ transformOrigin: '16px 26px', animation: 'stick-leg-walk-r 0.5s ease-in-out infinite' }}>
|
|
3146
|
+
<line x1="16" y1="26" x2="20" y2="36" {...strokeProps} />
|
|
3147
|
+
</g>
|
|
3148
|
+
{/* sparks from hammer */}
|
|
3149
|
+
<text x="26" y="22" fill="#ff9500" fontSize="6" style={{ animation: 'stick-spark 0.5s ease-in-out infinite' }}>✦</text>
|
|
3150
|
+
</svg>
|
|
3151
|
+
);
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
// idle — standing, waving
|
|
3155
|
+
return (
|
|
3156
|
+
<svg width="32" height="40" viewBox="0 0 32 40">
|
|
3157
|
+
<circle cx="16" cy="8" r="3" {...strokeProps} fill={color} />
|
|
3158
|
+
{/* eyes dots */}
|
|
3159
|
+
<circle cx="15" cy="7" r="0.6" fill={accentColor} />
|
|
3160
|
+
<circle cx="17" cy="7" r="0.6" fill={accentColor} />
|
|
3161
|
+
<line x1="16" y1="11" x2="16" y2="26" {...strokeProps} />
|
|
3162
|
+
{/* left arm down */}
|
|
3163
|
+
<line x1="16" y1="14" x2="12" y2="22" {...strokeProps} />
|
|
3164
|
+
{/* right arm waving */}
|
|
3165
|
+
<g style={{ transformOrigin: '16px 14px', animation: 'stick-arm-wave 2s ease-in-out infinite' }}>
|
|
3166
|
+
<line x1="16" y1="14" x2="22" y2="14" {...strokeProps} />
|
|
3167
|
+
</g>
|
|
3168
|
+
<line x1="16" y1="26" x2="12" y2="36" {...strokeProps} />
|
|
3169
|
+
<line x1="16" y1="26" x2="20" y2="36" {...strokeProps} />
|
|
3170
|
+
</svg>
|
|
3171
|
+
);
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
function WorkerMascot({ taskStatus, smithStatus, seed, accentColor, theme }: { taskStatus: string; smithStatus: string; seed: number; accentColor: string; theme: MascotTheme }) {
|
|
3175
|
+
if (theme === 'off') return null;
|
|
3176
|
+
|
|
3177
|
+
let pose: MascotPose = 'idle';
|
|
3178
|
+
let animation = 'mascot-idle 3s ease-in-out infinite';
|
|
3179
|
+
let title = 'Ready for work';
|
|
3180
|
+
const color = '#e6edf3';
|
|
3181
|
+
|
|
3182
|
+
if (smithStatus === 'down') {
|
|
3183
|
+
pose = 'sleep';
|
|
3184
|
+
animation = 'mascot-sleep 2.5s ease-in-out infinite';
|
|
3185
|
+
title = 'Smith is down — sleeping';
|
|
3186
|
+
} else if (taskStatus === 'running') {
|
|
3187
|
+
pose = 'work';
|
|
3188
|
+
animation = 'mascot-work 0.6s ease-in-out infinite';
|
|
3189
|
+
title = 'Hard at work!';
|
|
3190
|
+
} else if (taskStatus === 'done') {
|
|
3191
|
+
pose = 'done';
|
|
3192
|
+
// Celebrate 2 times (~2.4s total), then hold the pose quietly
|
|
3193
|
+
animation = 'mascot-celebrate 2.4s ease-in-out forwards';
|
|
3194
|
+
title = 'Task done!';
|
|
3195
|
+
} else if (taskStatus === 'failed') {
|
|
3196
|
+
pose = 'fail';
|
|
3197
|
+
animation = 'mascot-fall 0.8s ease-out forwards';
|
|
3198
|
+
title = 'Task failed';
|
|
3199
|
+
} else if (smithStatus === 'starting') {
|
|
3200
|
+
pose = 'wake';
|
|
3201
|
+
animation = 'mascot-sleep 1.8s ease-in-out infinite';
|
|
3202
|
+
title = 'Waking up...';
|
|
3203
|
+
} else {
|
|
3204
|
+
animation = 'mascot-idle 3s ease-in-out infinite';
|
|
3205
|
+
title = 'Ready for work';
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
let figure: React.ReactNode;
|
|
3209
|
+
if (theme === 'stick') figure = <StickFigure pose={pose} color={color} accentColor={accentColor} />;
|
|
3210
|
+
else if (theme === 'cat') figure = <StickCat pose={pose} color={color} accentColor={accentColor} />;
|
|
3211
|
+
else if (theme === 'dog') figure = <StickDog pose={pose} color={color} accentColor={accentColor} />;
|
|
3212
|
+
else if (theme === 'pig') figure = <StickPig pose={pose} color={color} accentColor={accentColor} />;
|
|
3213
|
+
else figure = <EmojiMascot pose={pose} seed={seed} />;
|
|
3214
|
+
|
|
3215
|
+
return (
|
|
3216
|
+
<div
|
|
3217
|
+
className="absolute pointer-events-none select-none"
|
|
3218
|
+
style={{
|
|
3219
|
+
top: '-36px',
|
|
3220
|
+
right: '-8px',
|
|
3221
|
+
animation,
|
|
3222
|
+
filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.6))',
|
|
3223
|
+
zIndex: 10,
|
|
3224
|
+
transformOrigin: 'bottom center',
|
|
3225
|
+
}}
|
|
3226
|
+
title={title}
|
|
3227
|
+
>
|
|
3228
|
+
{figure}
|
|
3229
|
+
</div>
|
|
3230
|
+
);
|
|
3231
|
+
}
|
|
3232
|
+
|
|
2515
3233
|
function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
|
|
2516
|
-
const { config, state, colorIdx, previewLines, projectPath, workspaceId, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, inboxPending = 0, inboxFailed = 0 } = data;
|
|
3234
|
+
const { config, state, colorIdx, previewLines, projectPath, workspaceId, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, onSaveAsTemplate, mascotTheme, inboxPending = 0, inboxFailed = 0 } = data;
|
|
2517
3235
|
const c = COLORS[colorIdx % COLORS.length];
|
|
2518
3236
|
const smithStatus = state?.smithStatus || 'down';
|
|
2519
3237
|
const taskStatus = state?.taskStatus || 'idle';
|
|
@@ -2524,10 +3242,15 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
|
|
|
2524
3242
|
const step = currentStep !== undefined ? config.steps[currentStep] : undefined;
|
|
2525
3243
|
const isApprovalPending = taskStatus === 'idle' && smithStatus === 'active';
|
|
2526
3244
|
|
|
3245
|
+
// Stable seed for mascot character from agent id
|
|
3246
|
+
const mascotSeed = config.id.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
|
|
3247
|
+
|
|
2527
3248
|
return (
|
|
2528
|
-
<div className="w-52 flex flex-col rounded-lg select-none"
|
|
3249
|
+
<div className="w-52 flex flex-col rounded-lg select-none relative"
|
|
2529
3250
|
style={{ border: `1px solid ${c.border}${taskStatus === 'running' ? '90' : '40'}`, background: c.bg,
|
|
2530
3251
|
boxShadow: taskInfo.glow ? `0 0 12px ${taskInfo.color}25` : smithInfo.glow ? `0 0 8px ${smithInfo.color}15` : 'none' }}>
|
|
3252
|
+
<style>{MASCOT_STYLES}</style>
|
|
3253
|
+
<WorkerMascot taskStatus={taskStatus} smithStatus={smithStatus} seed={mascotSeed} accentColor={c.accent} theme={mascotTheme} />
|
|
2531
3254
|
<Handle type="target" position={Position.Left} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
|
|
2532
3255
|
<Handle type="source" position={Position.Right} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
|
|
2533
3256
|
|
|
@@ -2649,6 +3372,8 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
|
|
|
2649
3372
|
className="text-[9px] text-gray-600 hover:text-purple-400 px-1" title="Memory">🧠</button>
|
|
2650
3373
|
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowLog(); }}
|
|
2651
3374
|
className="text-[9px] text-gray-600 hover:text-gray-300 px-1" title="Logs">📋</button>
|
|
3375
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onSaveAsTemplate(); }}
|
|
3376
|
+
className="text-[9px] text-gray-600 hover:text-yellow-400 px-1" title="Save as template">💾</button>
|
|
2652
3377
|
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onEdit(); }}
|
|
2653
3378
|
className="text-[9px] text-gray-600 hover:text-blue-400 px-1">✏️</button>
|
|
2654
3379
|
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onRemove(); }}
|
|
@@ -2682,6 +3407,14 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2682
3407
|
const [memoryTarget, setMemoryTarget] = useState<{ id: string; label: string } | null>(null);
|
|
2683
3408
|
const [inboxTarget, setInboxTarget] = useState<{ id: string; label: string } | null>(null);
|
|
2684
3409
|
const [showBusPanel, setShowBusPanel] = useState(false);
|
|
3410
|
+
const [mascotTheme, setMascotTheme] = useState<MascotTheme>(() => {
|
|
3411
|
+
if (typeof window === 'undefined') return 'off';
|
|
3412
|
+
return (localStorage.getItem('forge.mascotTheme') as MascotTheme) || 'off';
|
|
3413
|
+
});
|
|
3414
|
+
const updateMascotTheme = (t: MascotTheme) => {
|
|
3415
|
+
setMascotTheme(t);
|
|
3416
|
+
if (typeof window !== 'undefined') localStorage.setItem('forge.mascotTheme', t);
|
|
3417
|
+
};
|
|
2685
3418
|
const [floatingTerminals, setFloatingTerminals] = useState<{ agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string>; isPrimary?: boolean; skipPermissions?: boolean; persistentSession?: boolean; boundSessionId?: string; initialPos?: { x: number; y: number } }[]>([]);
|
|
2686
3419
|
const [termPicker, setTermPicker] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; supportsSession?: boolean; currentSessionId: string | null; initialPos?: { x: number; y: number } } | null>(null);
|
|
2687
3420
|
|
|
@@ -2769,6 +3502,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2769
3502
|
},
|
|
2770
3503
|
onPause: () => wsApi(workspaceId!, 'pause', { agentId: agent.id }),
|
|
2771
3504
|
onStop: () => wsApi(workspaceId!, 'stop', { agentId: agent.id }),
|
|
3505
|
+
mascotTheme,
|
|
2772
3506
|
onMarkIdle: () => wsApi(workspaceId!, 'mark_done', { agentId: agent.id, notify: false }),
|
|
2773
3507
|
onMarkDone: (notify: boolean) => wsApi(workspaceId!, 'mark_done', { agentId: agent.id, notify }),
|
|
2774
3508
|
onMarkFailed: (notify: boolean) => wsApi(workspaceId!, 'mark_failed', { agentId: agent.id, notify }),
|
|
@@ -2804,6 +3538,20 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2804
3538
|
const currentSessionId = resolveRes?.currentSessionId ?? null;
|
|
2805
3539
|
setTermPicker({ agent, sessName, workDir, supportsSession: resolveRes?.supportsSession ?? true, currentSessionId, initialPos });
|
|
2806
3540
|
},
|
|
3541
|
+
onSaveAsTemplate: async () => {
|
|
3542
|
+
const name = prompt('Template name:', agent.label);
|
|
3543
|
+
if (!name) return;
|
|
3544
|
+
const desc = prompt('Description (optional):', '');
|
|
3545
|
+
try {
|
|
3546
|
+
await fetch('/api/smith-templates', {
|
|
3547
|
+
method: 'POST',
|
|
3548
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3549
|
+
body: JSON.stringify({ config: agent, name, icon: agent.icon, description: desc || '' }),
|
|
3550
|
+
});
|
|
3551
|
+
} catch {
|
|
3552
|
+
alert('Failed to save template');
|
|
3553
|
+
}
|
|
3554
|
+
},
|
|
2807
3555
|
onSwitchSession: async () => {
|
|
2808
3556
|
if (!workspaceId) return;
|
|
2809
3557
|
setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
|
|
@@ -2822,7 +3570,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2822
3570
|
};
|
|
2823
3571
|
});
|
|
2824
3572
|
});
|
|
2825
|
-
}, [agents, states, logPreview, workspaceId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
3573
|
+
}, [agents, states, logPreview, workspaceId, mascotTheme]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
2826
3574
|
|
|
2827
3575
|
// Derive edges from dependsOn
|
|
2828
3576
|
const rfEdges = useMemo(() => {
|
|
@@ -2994,6 +3742,16 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2994
3742
|
</>
|
|
2995
3743
|
)}
|
|
2996
3744
|
<div className="ml-auto flex items-center gap-2">
|
|
3745
|
+
<select value={mascotTheme} onChange={e => updateMascotTheme(e.target.value as MascotTheme)}
|
|
3746
|
+
className="text-[8px] px-1.5 py-0.5 rounded border border-[#30363d] bg-[#0d1117] text-gray-500 hover:text-white hover:border-[#58a6ff]/60 cursor-pointer focus:outline-none"
|
|
3747
|
+
title="Mascot theme">
|
|
3748
|
+
<option value="stick">🏃 Stick</option>
|
|
3749
|
+
<option value="cat">🐱 Cat</option>
|
|
3750
|
+
<option value="dog">🐶 Dog</option>
|
|
3751
|
+
<option value="pig">🐷 Pig</option>
|
|
3752
|
+
<option value="emoji">🎭 Emoji</option>
|
|
3753
|
+
<option value="off">⊘ Off</option>
|
|
3754
|
+
</select>
|
|
2997
3755
|
<button onClick={() => setShowBusPanel(true)}
|
|
2998
3756
|
className={`text-[8px] px-2 py-0.5 rounded border border-[#30363d] hover:border-[#58a6ff]/60 ${busLog.length > 0 ? 'text-[#58a6ff]' : 'text-gray-500'}`}>
|
|
2999
3757
|
📡 Logs{busLog.length > 0 ? ` (${busLog.length})` : ''}
|