jettypod 4.4.118 → 4.4.121
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/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- package/docs/bdd-guidance.md +0 -390
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
1
|
import { useState, useEffect } from 'react';
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
2
|
+
import { m, AnimatePresence, useReducedMotion } from 'framer-motion';
|
|
3
|
+
import { lazy, Suspense } from 'react';
|
|
4
|
+
|
|
5
|
+
const LazyMarkdown = lazy(() => import('./LazyMarkdown'));
|
|
6
6
|
|
|
7
7
|
const STORAGE_KEY = 'jettypod-dismissed-tips';
|
|
8
8
|
|
|
@@ -27,8 +27,7 @@ function dismissTip(tipId: string): void {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
const CARD_SHADOW = '0 1px 2px rgba(0,0,0,0.02)';
|
|
30
|
+
import { shadow } from '@/lib/shadows';
|
|
32
31
|
|
|
33
32
|
interface TipCardProps {
|
|
34
33
|
tipId: string;
|
|
@@ -56,7 +55,7 @@ export function TipCard({ tipId, icon, title, body, onDismiss }: TipCardProps) {
|
|
|
56
55
|
return (
|
|
57
56
|
<AnimatePresence>
|
|
58
57
|
{!dismissed && (
|
|
59
|
-
<
|
|
58
|
+
<m.div
|
|
60
59
|
data-testid={`tip-card-${tipId}`}
|
|
61
60
|
initial={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 8 }}
|
|
62
61
|
animate={prefersReducedMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
|
|
@@ -67,14 +66,14 @@ export function TipCard({ tipId, icon, title, body, onDismiss }: TipCardProps) {
|
|
|
67
66
|
transition={{ duration: prefersReducedMotion ? 0.15 : 0.35, ease: [0.22, 1, 0.36, 1] }}
|
|
68
67
|
style={{
|
|
69
68
|
background: 'linear-gradient(135deg, #f0fdfa 0%, #f0fdfb 100%)',
|
|
70
|
-
border: '
|
|
69
|
+
border: '2px solid #ccfbf1',
|
|
71
70
|
borderRadius: 12,
|
|
72
71
|
padding: 14,
|
|
73
|
-
boxShadow:
|
|
72
|
+
boxShadow: shadow.sm,
|
|
74
73
|
}}
|
|
75
74
|
>
|
|
76
75
|
{/* Header: icon + label/title */}
|
|
77
|
-
<div style={{ display: 'flex', alignItems: 'center', gap:
|
|
76
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
|
|
78
77
|
<div
|
|
79
78
|
style={{
|
|
80
79
|
width: 34,
|
|
@@ -122,34 +121,34 @@ export function TipCard({ tipId, icon, title, body, onDismiss }: TipCardProps) {
|
|
|
122
121
|
fontSize: 13,
|
|
123
122
|
lineHeight: 1.55,
|
|
124
123
|
color: '#52525b',
|
|
125
|
-
marginLeft:
|
|
124
|
+
marginLeft: 50,
|
|
126
125
|
}}
|
|
127
126
|
>
|
|
128
127
|
{typeof body === 'string' ? (
|
|
129
|
-
<
|
|
128
|
+
<LazyMarkdown
|
|
130
129
|
components={{
|
|
131
130
|
p: ({ children }) => <p style={{ margin: '0 0 8px 0' }}>{children}</p>,
|
|
132
131
|
strong: ({ children }) => <strong style={{ fontWeight: 600, color: '#3f3f46' }}>{children}</strong>,
|
|
133
132
|
}}
|
|
134
133
|
>
|
|
135
134
|
{body.replace(/\n/g, ' \n')}
|
|
136
|
-
</
|
|
135
|
+
</LazyMarkdown>
|
|
137
136
|
) : body}
|
|
138
137
|
</div>
|
|
139
138
|
|
|
140
139
|
{/* Footer: Got it button */}
|
|
141
|
-
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop:
|
|
140
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
|
|
142
141
|
<button
|
|
143
142
|
onClick={handleDismiss}
|
|
144
143
|
data-testid={`tip-dismiss-${tipId}`}
|
|
145
144
|
style={{
|
|
146
145
|
background: 'transparent',
|
|
147
|
-
border: '
|
|
146
|
+
border: '2px solid #99f6e4',
|
|
148
147
|
fontSize: 12,
|
|
149
148
|
fontWeight: 600,
|
|
150
149
|
color: '#0d9488',
|
|
151
150
|
cursor: 'pointer',
|
|
152
|
-
padding: '
|
|
151
|
+
padding: '6px 16px',
|
|
153
152
|
borderRadius: 8,
|
|
154
153
|
transition: 'background 0.15s, border-color 0.15s',
|
|
155
154
|
}}
|
|
@@ -165,7 +164,7 @@ export function TipCard({ tipId, icon, title, body, onDismiss }: TipCardProps) {
|
|
|
165
164
|
Got it
|
|
166
165
|
</button>
|
|
167
166
|
</div>
|
|
168
|
-
</
|
|
167
|
+
</m.div>
|
|
169
168
|
)}
|
|
170
169
|
</AnimatePresence>
|
|
171
170
|
);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
'use client';
|
|
2
1
|
|
|
3
2
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
|
4
|
-
import { AnimatePresence,
|
|
3
|
+
import { AnimatePresence, m } from 'framer-motion';
|
|
5
4
|
|
|
6
5
|
interface Toast {
|
|
7
6
|
id: string;
|
|
@@ -52,16 +51,16 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|
|
52
51
|
|
|
53
52
|
function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: string) => void }) {
|
|
54
53
|
return (
|
|
55
|
-
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-
|
|
54
|
+
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-3" data-testid="toast-container">
|
|
56
55
|
<AnimatePresence mode="popLayout">
|
|
57
56
|
{toasts.map(toast => (
|
|
58
|
-
<
|
|
57
|
+
<m.div
|
|
59
58
|
key={toast.id}
|
|
60
59
|
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
|
61
60
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
62
61
|
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
|
63
62
|
transition={{ duration: 0.2 }}
|
|
64
|
-
className={`px-
|
|
63
|
+
className={`px-5 py-3 rounded-lg shadow-lg text-base font-medium cursor-pointer ${
|
|
65
64
|
toast.type === 'success'
|
|
66
65
|
? 'bg-green-600 text-white'
|
|
67
66
|
: toast.type === 'error'
|
|
@@ -72,7 +71,7 @@ function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id
|
|
|
72
71
|
data-testid="toast"
|
|
73
72
|
>
|
|
74
73
|
{toast.message}
|
|
75
|
-
</
|
|
74
|
+
</m.div>
|
|
76
75
|
))}
|
|
77
76
|
</AnimatePresence>
|
|
78
77
|
</div>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
|
|
2
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { TYPE_ICONS } from '@/lib/constants';
|
|
5
|
+
import { shadow } from '@/lib/shadows';
|
|
6
|
+
|
|
7
|
+
const TYPE_ICON_SRCS: Record<string, { src: string; label: string }> = {
|
|
8
|
+
bug: { src: '/bug-icon.png', label: 'Bug' },
|
|
9
|
+
chore: { src: '/wrench-icon.png', label: 'Chore' },
|
|
10
|
+
epic: { src: '/buoy-icon.png', label: 'Epic' },
|
|
11
|
+
feature: { src: '/star-icon.png', label: 'Feature' },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function TypeIcon({ type, className }: { type: string; className?: string }) {
|
|
15
|
+
const svg = TYPE_ICON_SRCS[type];
|
|
16
|
+
const [tooltip, setTooltip] = useState<{ x: number; y: number } | null>(null);
|
|
17
|
+
const [mounted, setMounted] = useState(false);
|
|
18
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
19
|
+
|
|
20
|
+
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
setMounted(true);
|
|
24
|
+
setPortalRoot(document.getElementById('tooltip-root'));
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
const showTooltip = useCallback(() => {
|
|
28
|
+
if (ref.current) {
|
|
29
|
+
const rect = ref.current.getBoundingClientRect();
|
|
30
|
+
setTooltip({ x: rect.left + rect.width / 2, y: rect.bottom + 4 });
|
|
31
|
+
}
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
const hideTooltip = useCallback(() => setTooltip(null), []);
|
|
35
|
+
|
|
36
|
+
if (svg) {
|
|
37
|
+
return (
|
|
38
|
+
<span ref={ref} className="inline-flex" onMouseEnter={showTooltip} onMouseLeave={hideTooltip}>
|
|
39
|
+
<img src={svg.src} alt="" className={`object-contain ${className || "w-6 h-6"}`} />
|
|
40
|
+
{mounted && tooltip && portalRoot && createPortal(
|
|
41
|
+
<span
|
|
42
|
+
className="pointer-events-none fixed"
|
|
43
|
+
style={{ left: tooltip.x, top: tooltip.y, transform: 'translateX(-50%)' }}
|
|
44
|
+
>
|
|
45
|
+
<span className="block rounded-lg bg-white dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-xs px-2 py-1 whitespace-nowrap" style={{ boxShadow: shadow.sm }}>
|
|
46
|
+
{svg.label}
|
|
47
|
+
</span>
|
|
48
|
+
</span>,
|
|
49
|
+
portalRoot
|
|
50
|
+
)}
|
|
51
|
+
</span>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return <>{TYPE_ICONS[type] || '📄'}</>;
|
|
55
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
|
|
4
|
+
type ViewMode = 'summary' | 'detail' | 'raw';
|
|
5
|
+
|
|
6
|
+
const READOUT_FILTERS = [
|
|
7
|
+
{ id: 'init', label: 'Init', types: ['system'] },
|
|
8
|
+
{ id: 'streaming', label: 'Streaming', types: ['content_block_start', 'content_block_delta', 'content_block_stop', 'message_start', 'message_delta', 'message_stop'] },
|
|
9
|
+
{ id: 'messages', label: 'Messages', types: ['assistant'] },
|
|
10
|
+
{ id: 'tools', label: 'Tools', types: ['user'] },
|
|
11
|
+
{ id: 'completion', label: 'Completion', types: ['result', 'done'] },
|
|
12
|
+
{ id: 'errors', label: 'Errors', types: ['error'] },
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
type ReadoutFilterId = typeof READOUT_FILTERS[number]['id'];
|
|
16
|
+
|
|
17
|
+
interface ViewModeToolbarProps {
|
|
18
|
+
viewMode: ViewMode;
|
|
19
|
+
onViewModeChange: (mode: ViewMode) => void;
|
|
20
|
+
hasIntermediates: boolean;
|
|
21
|
+
allExpanded: boolean;
|
|
22
|
+
onToggleExpandAll: () => void;
|
|
23
|
+
activeFilters: Set<ReadoutFilterId>;
|
|
24
|
+
onToggleFilter: (id: ReadoutFilterId) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MODE_LABELS: { mode: ViewMode; label: string }[] = [
|
|
28
|
+
{ mode: 'summary', label: 'Summary' },
|
|
29
|
+
{ mode: 'detail', label: 'Detail' },
|
|
30
|
+
{ mode: 'raw', label: 'Raw' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export function ViewModeToolbar({
|
|
34
|
+
viewMode,
|
|
35
|
+
onViewModeChange,
|
|
36
|
+
hasIntermediates,
|
|
37
|
+
allExpanded,
|
|
38
|
+
onToggleExpandAll,
|
|
39
|
+
activeFilters,
|
|
40
|
+
onToggleFilter,
|
|
41
|
+
}: ViewModeToolbarProps) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="px-5 py-2 border-b border-zinc-100 flex-shrink-0" data-testid="view-mode-toolbar">
|
|
44
|
+
<div className="flex items-center justify-between">
|
|
45
|
+
{/* Left: mode toggle */}
|
|
46
|
+
<div className="flex items-center gap-1" data-testid="mode-toggle">
|
|
47
|
+
{MODE_LABELS.map(({ mode, label }, i) => (
|
|
48
|
+
<span key={mode} className="flex items-center">
|
|
49
|
+
{i > 0 && <span className="text-zinc-300 mx-1 text-xs select-none">|</span>}
|
|
50
|
+
<button
|
|
51
|
+
onClick={() => onViewModeChange(mode)}
|
|
52
|
+
className={`text-xs cursor-pointer transition-colors duration-200 ease-out ${
|
|
53
|
+
viewMode === mode
|
|
54
|
+
? 'text-zinc-900 font-semibold underline underline-offset-4 decoration-zinc-900'
|
|
55
|
+
: 'text-zinc-400 hover:text-zinc-600'
|
|
56
|
+
}`}
|
|
57
|
+
data-testid={`mode-${mode}`}
|
|
58
|
+
aria-pressed={viewMode === mode}
|
|
59
|
+
>
|
|
60
|
+
{label}
|
|
61
|
+
</button>
|
|
62
|
+
</span>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Right: contextual action (non-raw modes) */}
|
|
67
|
+
{viewMode === 'detail' && hasIntermediates && (
|
|
68
|
+
<button
|
|
69
|
+
onClick={onToggleExpandAll}
|
|
70
|
+
className="text-xs text-zinc-400 hover:text-zinc-600 cursor-pointer transition-colors duration-200 ease-out"
|
|
71
|
+
data-testid="expand-collapse-all"
|
|
72
|
+
>
|
|
73
|
+
{allExpanded ? 'Collapse all' : 'Expand all'}
|
|
74
|
+
</button>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{/* Raw filter chips: own row, centered */}
|
|
79
|
+
{viewMode === 'raw' && (
|
|
80
|
+
<div className="flex justify-center mt-2" data-testid="readout-filter-chips">
|
|
81
|
+
<div className="flex gap-1.5 flex-wrap justify-center">
|
|
82
|
+
{READOUT_FILTERS.map(f => (
|
|
83
|
+
<button
|
|
84
|
+
key={f.id}
|
|
85
|
+
onClick={() => onToggleFilter(f.id)}
|
|
86
|
+
className={`text-xs px-2 py-0.5 rounded-full cursor-pointer transition-colors duration-200 ease-out ${
|
|
87
|
+
activeFilters.has(f.id)
|
|
88
|
+
? 'bg-purple-100 text-purple-700'
|
|
89
|
+
: 'bg-zinc-100 text-zinc-400'
|
|
90
|
+
}`}
|
|
91
|
+
data-testid={`readout-filter-${f.id}`}
|
|
92
|
+
>
|
|
93
|
+
{f.label}
|
|
94
|
+
</button>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type { ViewMode, ReadoutFilterId, ViewModeToolbarProps };
|
|
104
|
+
export { READOUT_FILTERS };
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
'use client';
|
|
2
1
|
|
|
3
2
|
import { useState, useEffect, useRef, ReactNode } from 'react';
|
|
4
3
|
|
|
5
|
-
type AnimationPhase = 'idle' | '
|
|
4
|
+
type AnimationPhase = 'idle' | 'video-playing' | 'collapsing' | 'complete';
|
|
6
5
|
|
|
7
6
|
interface WaveCompletionAnimationProps {
|
|
8
7
|
isPlaying: boolean;
|
|
@@ -13,16 +12,15 @@ interface WaveCompletionAnimationProps {
|
|
|
13
12
|
export function WaveCompletionAnimation({ isPlaying, onComplete, children }: WaveCompletionAnimationProps) {
|
|
14
13
|
const [phase, setPhase] = useState<AnimationPhase>('idle');
|
|
15
14
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
15
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
16
16
|
const hasStartedRef = useRef(false);
|
|
17
17
|
const timeoutRefs = useRef<NodeJS.Timeout[]>([]);
|
|
18
18
|
|
|
19
|
-
// Clear all pending timeouts
|
|
20
19
|
const clearAllTimeouts = () => {
|
|
21
20
|
timeoutRefs.current.forEach(clearTimeout);
|
|
22
21
|
timeoutRefs.current = [];
|
|
23
22
|
};
|
|
24
23
|
|
|
25
|
-
// Handle video load errors - skip animation and complete immediately
|
|
26
24
|
const handleVideoError = () => {
|
|
27
25
|
if (hasStartedRef.current) {
|
|
28
26
|
clearAllTimeouts();
|
|
@@ -35,56 +33,39 @@ export function WaveCompletionAnimation({ isPlaying, onComplete, children }: Wav
|
|
|
35
33
|
if (isPlaying && !hasStartedRef.current) {
|
|
36
34
|
hasStartedRef.current = true;
|
|
37
35
|
|
|
38
|
-
|
|
39
|
-
const prefersReducedMotion = typeof window !== 'undefined' &&
|
|
40
|
-
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
36
|
+
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
41
37
|
|
|
42
38
|
if (prefersReducedMotion) {
|
|
43
|
-
// Skip animation entirely - complete immediately
|
|
44
39
|
setPhase('complete');
|
|
45
40
|
onComplete();
|
|
46
41
|
return;
|
|
47
42
|
}
|
|
48
43
|
|
|
49
|
-
// Phase 1: Content
|
|
50
|
-
setPhase('
|
|
44
|
+
// Phase 1: Content disappears instantly, video starts playing
|
|
45
|
+
setPhase('video-playing');
|
|
51
46
|
|
|
52
|
-
// Phase 2: Video starts playing
|
|
53
47
|
const video = videoRef.current;
|
|
54
48
|
if (video) {
|
|
55
49
|
video.currentTime = 0;
|
|
56
|
-
video.play().catch(() =>
|
|
57
|
-
// Video play failed - skip animation
|
|
58
|
-
handleVideoError();
|
|
59
|
-
});
|
|
50
|
+
video.play().catch(() => handleVideoError());
|
|
60
51
|
}
|
|
61
52
|
|
|
53
|
+
// Phase 2: After 1.5s of video, collapse the card (opacity + height)
|
|
62
54
|
const t1 = setTimeout(() => {
|
|
63
|
-
setPhase('
|
|
64
|
-
},
|
|
55
|
+
setPhase('collapsing');
|
|
56
|
+
}, 1500);
|
|
65
57
|
timeoutRefs.current.push(t1);
|
|
66
58
|
|
|
67
|
-
// Phase 3: After
|
|
59
|
+
// Phase 3: After collapse transition (0.5s), fire onComplete
|
|
68
60
|
const t2 = setTimeout(() => {
|
|
69
|
-
|
|
70
|
-
}, 5000);
|
|
71
|
-
timeoutRefs.current.push(t2);
|
|
72
|
-
|
|
73
|
-
// Phase 4: After fade completes (1.5s), call onComplete
|
|
74
|
-
const t3 = setTimeout(() => {
|
|
75
|
-
if (video) {
|
|
76
|
-
video.pause();
|
|
77
|
-
}
|
|
61
|
+
if (video) video.pause();
|
|
78
62
|
setPhase('complete');
|
|
79
63
|
onComplete();
|
|
80
|
-
},
|
|
81
|
-
timeoutRefs.current.push(
|
|
64
|
+
}, 2000);
|
|
65
|
+
timeoutRefs.current.push(t2);
|
|
82
66
|
}
|
|
83
67
|
}, [isPlaying, onComplete]);
|
|
84
68
|
|
|
85
|
-
// Reset when isPlaying becomes false — but only if not already complete.
|
|
86
|
-
// If the animation finished naturally (phase === 'complete'), skip the reset
|
|
87
|
-
// to avoid snapping opacity back to 1 before the CSS transition visually ends.
|
|
88
69
|
useEffect(() => {
|
|
89
70
|
if (!isPlaying && phase !== 'complete') {
|
|
90
71
|
clearAllTimeouts();
|
|
@@ -93,49 +74,55 @@ export function WaveCompletionAnimation({ isPlaying, onComplete, children }: Wav
|
|
|
93
74
|
}
|
|
94
75
|
}, [isPlaying, phase]);
|
|
95
76
|
|
|
96
|
-
// Cleanup on unmount
|
|
97
77
|
useEffect(() => {
|
|
98
78
|
return () => clearAllTimeouts();
|
|
99
79
|
}, []);
|
|
100
80
|
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const cardOpacity = phase === 'card-fade' || phase === 'complete' ? 0 : 1;
|
|
81
|
+
const isIdle = phase === 'idle';
|
|
82
|
+
const isCollapsing = phase === 'collapsing' || phase === 'complete';
|
|
104
83
|
|
|
105
84
|
return (
|
|
106
85
|
<div
|
|
107
|
-
|
|
86
|
+
ref={containerRef}
|
|
108
87
|
style={{
|
|
109
|
-
|
|
110
|
-
|
|
88
|
+
overflow: 'hidden',
|
|
89
|
+
opacity: isCollapsing ? 0 : 1,
|
|
90
|
+
transform: isCollapsing ? 'scaleY(0)' : 'scaleY(1)',
|
|
91
|
+
transformOrigin: 'top',
|
|
92
|
+
transition: isCollapsing
|
|
93
|
+
? 'opacity 0.4s ease-out, transform 0.5s ease-out'
|
|
94
|
+
: undefined,
|
|
111
95
|
}}
|
|
112
96
|
>
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
97
|
+
<div className="relative overflow-hidden rounded-xl">
|
|
98
|
+
{/* Wave video - positioned behind content */}
|
|
99
|
+
<video
|
|
100
|
+
ref={videoRef}
|
|
101
|
+
className="absolute inset-0 w-full h-full object-cover rounded-xl"
|
|
102
|
+
style={{
|
|
103
|
+
opacity: isIdle ? 0 : 1,
|
|
104
|
+
transition: 'opacity 0.5s ease-in',
|
|
105
|
+
zIndex: 1,
|
|
106
|
+
}}
|
|
107
|
+
muted
|
|
108
|
+
playsInline
|
|
109
|
+
preload="none"
|
|
110
|
+
src="/assets/wave-completion.mp4"
|
|
111
|
+
onError={handleVideoError}
|
|
112
|
+
/>
|
|
113
|
+
|
|
114
|
+
{/* Card content - positioned above video */}
|
|
115
|
+
<div
|
|
116
|
+
style={{
|
|
117
|
+
opacity: isIdle ? 1 : 0,
|
|
118
|
+
transition: 'opacity 0.5s ease-out',
|
|
119
|
+
position: 'relative',
|
|
120
|
+
zIndex: 2,
|
|
121
|
+
background: 'inherit',
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
{children}
|
|
125
|
+
</div>
|
|
139
126
|
</div>
|
|
140
127
|
</div>
|
|
141
128
|
);
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import Image from 'next/image';
|
|
4
|
-
import type { RecentProject } from '@/lib/db-bridge';
|
|
1
|
+
import { Button } from '@/components/ui/Button';
|
|
2
|
+
import type { RecentProject } from '@/lib/tauri-bridge';
|
|
5
3
|
|
|
6
4
|
interface WelcomeScreenProps {
|
|
7
5
|
recentProjects?: RecentProject[];
|
|
@@ -17,16 +15,15 @@ export function WelcomeScreen({
|
|
|
17
15
|
onSelectRecentProject,
|
|
18
16
|
}: WelcomeScreenProps) {
|
|
19
17
|
return (
|
|
20
|
-
<div className="flex flex-col items-center justify-center min-h-screen bg-white dark:bg-zinc-900
|
|
21
|
-
<div className="max-w-md w-full space-y-
|
|
18
|
+
<div className="flex flex-col items-center justify-center min-h-screen bg-white dark:bg-zinc-900 px-8 py-6 overflow-y-auto">
|
|
19
|
+
<div className="max-w-md w-full space-y-6">
|
|
22
20
|
{/* Logo */}
|
|
23
21
|
<div className="flex flex-col items-center space-y-4">
|
|
24
|
-
<
|
|
22
|
+
<img
|
|
25
23
|
src="/jettypod_wordmark.png"
|
|
26
24
|
alt="JettyPod"
|
|
27
25
|
width={160}
|
|
28
26
|
height={40}
|
|
29
|
-
priority
|
|
30
27
|
/>
|
|
31
28
|
<p className="text-zinc-500 dark:text-zinc-400 text-center">
|
|
32
29
|
Select a project to get started
|
|
@@ -34,46 +31,33 @@ export function WelcomeScreen({
|
|
|
34
31
|
</div>
|
|
35
32
|
|
|
36
33
|
{/* Project Buttons */}
|
|
37
|
-
<div className="pt-4 space-y-
|
|
38
|
-
<
|
|
34
|
+
<div className="pt-4 space-y-4">
|
|
35
|
+
<Button
|
|
39
36
|
onClick={onNewProject}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
cursor: 'pointer',
|
|
43
|
-
background: 'linear-gradient(145deg, #ffffff 0%, #faf9f7 10%, #f0f4f4 35%, #c8d9da 55%, #819D9F 90%)',
|
|
44
|
-
color: '#3d4d4e',
|
|
45
|
-
boxShadow: `
|
|
46
|
-
0 1px 1px rgba(0, 0, 0, 0.02),
|
|
47
|
-
0 2px 4px rgba(0, 0, 0, 0.03),
|
|
48
|
-
0 6px 12px rgba(0, 0, 0, 0.05),
|
|
49
|
-
0 12px 24px rgba(0, 0, 0, 0.06),
|
|
50
|
-
0 20px 40px rgba(129, 157, 159, 0.2),
|
|
51
|
-
0 32px 64px rgba(129, 157, 159, 0.18),
|
|
52
|
-
inset 0 2px 4px rgba(255, 255, 255, 1),
|
|
53
|
-
inset 0 -2px 4px rgba(129, 157, 159, 0.05)
|
|
54
|
-
`,
|
|
55
|
-
}}
|
|
37
|
+
size="lg"
|
|
38
|
+
fullWidth
|
|
56
39
|
data-testid="new-project-button"
|
|
57
40
|
>
|
|
58
41
|
New Project
|
|
59
|
-
</
|
|
60
|
-
<
|
|
42
|
+
</Button>
|
|
43
|
+
<Button
|
|
61
44
|
onClick={onOpenProject}
|
|
62
|
-
|
|
63
|
-
|
|
45
|
+
variant="secondary"
|
|
46
|
+
size="lg"
|
|
47
|
+
fullWidth
|
|
64
48
|
data-testid="open-project-button"
|
|
65
49
|
>
|
|
66
50
|
Open Project
|
|
67
|
-
</
|
|
51
|
+
</Button>
|
|
68
52
|
</div>
|
|
69
53
|
|
|
70
54
|
{/* Recent Projects Section */}
|
|
71
|
-
<div className="pt-
|
|
55
|
+
<div className="pt-6 space-y-3" data-testid="recent-projects-section">
|
|
72
56
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
|
73
57
|
Recent Projects
|
|
74
58
|
</h2>
|
|
75
59
|
{recentProjects.length === 0 ? (
|
|
76
|
-
<div className="
|
|
60
|
+
<div className="rounded-lg p-4 text-zinc-500 dark:text-zinc-400 text-base text-center">
|
|
77
61
|
No recent projects
|
|
78
62
|
</div>
|
|
79
63
|
) : (
|
|
@@ -82,13 +66,13 @@ export function WelcomeScreen({
|
|
|
82
66
|
<button
|
|
83
67
|
key={project.path}
|
|
84
68
|
onClick={() => onSelectRecentProject?.(project)}
|
|
85
|
-
className="w-full text-left p-4 border border-zinc-200 dark:border-zinc-700
|
|
69
|
+
className="w-full text-left p-4 rounded-xl bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors duration-200 ease-out cursor-pointer"
|
|
86
70
|
data-testid={`recent-project-${project.name}`}
|
|
87
71
|
>
|
|
88
72
|
<div className="font-medium text-zinc-900 dark:text-zinc-100">
|
|
89
73
|
{project.name}
|
|
90
74
|
</div>
|
|
91
|
-
<div className="text-
|
|
75
|
+
<div className="text-base text-zinc-500 dark:text-zinc-400 truncate">
|
|
92
76
|
{project.path}
|
|
93
77
|
</div>
|
|
94
78
|
</button>
|
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
'use client';
|
|
2
1
|
|
|
3
2
|
import { CopyableId } from './CopyableId';
|
|
3
|
+
import { TypeIcon } from './TypeIcon';
|
|
4
4
|
|
|
5
5
|
interface WorkItemHeaderProps {
|
|
6
6
|
id: number;
|
|
7
7
|
title: string;
|
|
8
8
|
type: string;
|
|
9
|
-
typeIcon: string;
|
|
10
9
|
typeLabel: string;
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
export function WorkItemHeader({ id, title, type,
|
|
12
|
+
export function WorkItemHeader({ id, title, type, typeLabel }: WorkItemHeaderProps) {
|
|
14
13
|
return (
|
|
15
|
-
<div className="flex items-center gap-
|
|
16
|
-
<span
|
|
14
|
+
<div className="flex items-center gap-3 text-base text-zinc-500 mb-1.5">
|
|
15
|
+
<span className="flex items-center gap-1"><TypeIcon type={type} /> {typeLabel}</span>
|
|
17
16
|
<span>•</span>
|
|
18
17
|
<CopyableId id={id} title={title} type={type} />
|
|
19
18
|
</div>
|