coaia-visualizer 1.6.3 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/Dockerfile +61 -0
  2. package/Dockerfile.app +50 -0
  3. package/cli.ts +11 -5
  4. package/components/chart-detail-editable.tsx +2 -0
  5. package/components/chart-detail.tsx +6 -1
  6. package/components/edit-action-step.tsx +2 -0
  7. package/components/github-provenance.tsx +226 -0
  8. package/components/relation-graph.tsx +91 -27
  9. package/dist/cli.js +9 -5
  10. package/docker-build-push.sh +20 -0
  11. package/docker-entrypoint.sh +27 -0
  12. package/index.tsx +26 -0
  13. package/lib/chart-editor.ts +3 -3
  14. package/lib/github-provenance.ts +316 -0
  15. package/lib/jsonl-parser.ts +2 -2
  16. package/lib/types.ts +15 -2
  17. package/mcp/test_mcp/.gemini/settings.json +18 -0
  18. package/package.json +14 -13
  19. package/rispecs/README.md +170 -0
  20. package/rispecs/accountability-responsibility.rispec.md +110 -0
  21. package/rispecs/api-mcp-interface.spec.md +287 -0
  22. package/rispecs/app.spec.md +364 -0
  23. package/rispecs/chart-editing-workflow.spec.md +297 -0
  24. package/rispecs/chart-visualization-hierarchy.spec.md +235 -0
  25. package/rispecs/cli-mode.spec.md +224 -0
  26. package/rispecs/github-project-runtime-memory-integration.spec.md +381 -0
  27. package/rispecs/jsonl-parsing-data-types.spec.md +243 -0
  28. package/rispecs/narrative-beats-display.spec.md +189 -0
  29. package/rispecs/pde-integration.spec.md +280 -0
  30. package/rispecs/planning-integration.spec.md +329 -0
  31. package/rispecs/relation-graph-visualization.spec.md +171 -0
  32. package/rispecs/relation-to-mcp-structural-thinking.kin.md +65 -0
  33. package/rispecs/ui-component-library.spec.md +258 -0
  34. package/mcp/dist/api-client.d.ts +0 -138
  35. package/mcp/dist/api-client.d.ts.map +0 -1
  36. package/mcp/dist/api-client.js +0 -115
  37. package/mcp/dist/api-client.js.map +0 -1
  38. package/mcp/dist/index.d.ts +0 -2
  39. package/mcp/dist/index.d.ts.map +0 -1
  40. package/mcp/dist/index.js +0 -286
  41. package/mcp/dist/index.js.map +0 -1
  42. package/mcp/dist/tools/index.d.ts +0 -18
  43. package/mcp/dist/tools/index.d.ts.map +0 -1
  44. package/mcp/dist/tools/index.js +0 -322
  45. package/mcp/dist/tools/index.js.map +0 -1
  46. package/mcp/package-lock.json +0 -210
package/Dockerfile ADDED
@@ -0,0 +1,61 @@
1
+ # =============================================================================
2
+ # COAIA Visualizer — Docker Image
3
+ # jgwill/coaia:visualizer
4
+ #
5
+ # Usage:
6
+ # docker run --rm -p 4321:4321 \
7
+ # -v /abs/path/to/file.jsonl:/data/memory.jsonl \
8
+ # jgwill/coaia:visualizer
9
+ # =============================================================================
10
+
11
+ # ---- Build stage ----
12
+ FROM node:22-alpine AS builder
13
+
14
+ WORKDIR /app
15
+
16
+ # Install pnpm
17
+ RUN corepack enable && corepack prepare pnpm@latest --activate
18
+
19
+ # Copy lockfile + manifests first (layer cache)
20
+ COPY package.json pnpm-lock.yaml ./
21
+
22
+ # Install all deps — skip prepare hook (cli.ts not copied yet)
23
+ RUN pnpm install --no-frozen-lockfile --ignore-scripts
24
+
25
+ # Copy source
26
+ COPY . .
27
+
28
+ # Build CLI + Next.js
29
+ RUN pnpm run prepare && pnpm build
30
+
31
+ # ---- Runner stage ----
32
+ FROM node:22-alpine AS runner
33
+
34
+ WORKDIR /app
35
+
36
+ RUN corepack enable && corepack prepare pnpm@latest --activate
37
+
38
+ # Copy manifests + node_modules from builder, then prune dev deps
39
+ COPY package.json pnpm-lock.yaml ./
40
+ COPY --from=builder /app/node_modules ./node_modules
41
+ RUN pnpm prune --prod 2>/dev/null || true
42
+
43
+ # Copy built assets from builder
44
+ COPY --from=builder /app/.next ./.next
45
+ COPY --from=builder /app/public ./public
46
+ COPY --from=builder /app/next.config.mjs ./next.config.mjs
47
+
48
+ # Entrypoint script
49
+ COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
50
+ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
51
+
52
+ # Volume for user JSONL files
53
+ VOLUME ["/data"]
54
+
55
+ ENV NODE_ENV=production
56
+ ENV PORT=4321
57
+ ENV COAIAV_MEMORY_PATH=/data/memory.jsonl
58
+
59
+ EXPOSE 4321
60
+
61
+ ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
package/Dockerfile.app ADDED
@@ -0,0 +1,50 @@
1
+ # Build stage
2
+ FROM node:20-alpine AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ # Copy package files
7
+ COPY package.json pnpm-lock.yaml ./
8
+
9
+ # Install pnpm and dependencies
10
+ RUN npm install -g pnpm@latest && \
11
+ pnpm install --frozen-lockfile
12
+
13
+ # Copy application code
14
+ COPY . .
15
+
16
+ # Build Next.js application
17
+ RUN pnpm build
18
+
19
+ # Production stage
20
+ FROM node:20-alpine AS runner
21
+
22
+ WORKDIR /app
23
+
24
+ # Install pnpm
25
+ RUN npm install -g pnpm@latest
26
+
27
+ # Copy package files
28
+ COPY package.json pnpm-lock.yaml ./
29
+
30
+ # Install production dependencies only, skip prepare hook
31
+ RUN pnpm install --prod --frozen-lockfile --ignore-scripts
32
+
33
+ # Copy built application from builder
34
+ COPY --from=builder /app/.next ./.next
35
+ COPY --from=builder /app/public ./public
36
+ COPY --from=builder /app/next.config.mjs ./
37
+ COPY --from=builder /app/app ./app
38
+ COPY --from=builder /app/components ./components
39
+ COPY --from=builder /app/lib ./lib
40
+ COPY --from=builder /app/dist ./dist
41
+
42
+ # Create data directories
43
+ RUN mkdir -p /app/samples /app/test-data
44
+
45
+ ENV NODE_ENV=production
46
+ ENV PORT=4321
47
+
48
+ EXPOSE 4321
49
+
50
+ CMD ["pnpm", "start"]
package/cli.ts CHANGED
@@ -88,7 +88,7 @@ function loadConfig(args: minimist.ParsedArgs): Config {
88
88
  if (args['port'] || args['p']) {
89
89
  config.port = args['port'] || args['p'];
90
90
  }
91
- if (args['no-open']) {
91
+ if (args['no-open'] || args.open === false) {
92
92
  config.noOpen = true;
93
93
  }
94
94
  if (args['live']) {
@@ -242,11 +242,14 @@ EXAMPLES:
242
242
  }, 4000);
243
243
  }
244
244
 
245
- process.on('SIGINT', () => {
245
+ const shutdownDocker = () => {
246
246
  console.log('\n👋 Shutting down visualizer...');
247
247
  dockerProcess.kill();
248
248
  process.exit(0);
249
- });
249
+ };
250
+
251
+ process.on('SIGINT', shutdownDocker);
252
+ process.on('SIGTERM', shutdownDocker);
250
253
 
251
254
  dockerProcess.on('exit', (code) => { process.exit(code || 0); });
252
255
  return;
@@ -295,11 +298,14 @@ EXAMPLES:
295
298
  }
296
299
 
297
300
  // Handle shutdown
298
- process.on('SIGINT', () => {
301
+ const shutdownLocal = () => {
299
302
  console.log('\n👋 Shutting down visualizer...');
300
303
  nextProcess.kill();
301
304
  process.exit(0);
302
- });
305
+ };
306
+
307
+ process.on('SIGINT', shutdownLocal);
308
+ process.on('SIGTERM', shutdownLocal);
303
309
 
304
310
  nextProcess.on('exit', (code) => {
305
311
  process.exit(code || 0);
@@ -17,6 +17,7 @@ import { EditActionStep } from "./edit-action-step"
17
17
  import { AddActionStep } from "./add-action-step"
18
18
  import { EditChartDueDate } from "./edit-chart-due-date"
19
19
  import { ChartEditor } from "@/lib/chart-editor"
20
+ import { ProjectSourceBadge } from "./github-provenance"
20
21
 
21
22
  interface ChartDetailEditableProps {
22
23
  chart: Chart
@@ -89,6 +90,7 @@ export function ChartDetailEditable({
89
90
  <div className="flex items-center gap-4 text-sm text-muted-foreground">
90
91
  <span>Created {createdDate}</span>
91
92
  </div>
93
+ <ProjectSourceBadge entity={chart.chartEntity} className="mt-3" />
92
94
  </div>
93
95
  <div>
94
96
  <EditChartDueDate
@@ -8,6 +8,7 @@ import type { ParsedData, Chart, EntityRecord } from "@/lib/types"
8
8
  import { Target, MapPin, ListChecks, Network, BookOpen, Calendar, TrendingUp, CheckCircle2, Circle } from "lucide-react"
9
9
  import { NarrativeBeats } from "./narrative-beats"
10
10
  import { RelationGraph } from "./relation-graph"
11
+ import { ActionStepIssueBadge, ProjectSourceBadge } from "./github-provenance"
11
12
 
12
13
  interface ChartDetailProps {
13
14
  chart: Chart
@@ -41,6 +42,7 @@ export function ChartDetail({ chart, data }: ChartDetailProps) {
41
42
  </div>
42
43
  )}
43
44
  </div>
45
+ <ProjectSourceBadge entity={chart.chartEntity} className="mt-3" />
44
46
  </div>
45
47
  </div>
46
48
  </CardHeader>
@@ -194,7 +196,10 @@ function ActionItem({ action, index }: ActionItemProps) {
194
196
  </div>
195
197
  <div className="flex-1 min-w-0">
196
198
  <div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-2 mb-1">
197
- <span className="text-xs md:text-sm font-medium">Action {index}</span>
199
+ <div className="flex flex-wrap items-center gap-2">
200
+ <span className="text-xs md:text-sm font-medium">Action {index}</span>
201
+ <ActionStepIssueBadge action={action} />
202
+ </div>
198
203
  {dueDate && (
199
204
  <div className="flex items-center gap-1 text-xs text-muted-foreground">
200
205
  <Calendar className="w-3 h-3" />
@@ -8,6 +8,7 @@ import { Calendar } from "@/components/ui/calendar"
8
8
  import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
9
9
  import { format } from "date-fns"
10
10
  import type { EntityRecord } from "@/lib/types"
11
+ import { ActionStepIssueBadge } from "@/components/github-provenance"
11
12
 
12
13
  interface EditActionStepProps {
13
14
  action: EntityRecord
@@ -113,6 +114,7 @@ export function EditActionStep({
113
114
  <div className="flex items-start justify-between gap-2">
114
115
  <div className="flex items-center gap-2 flex-wrap">
115
116
  <span className="text-sm font-medium">Action {index}</span>
117
+ <ActionStepIssueBadge action={action} />
116
118
  {isTelescopedChart && (
117
119
  <span className="text-xs bg-primary text-primary-foreground px-2.5 py-1 rounded-md font-semibold shadow-sm">
118
120
  📊 Chart
@@ -0,0 +1,226 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import type { LucideIcon } from "lucide-react"
5
+ import { AlertTriangle, CheckCircle2, Cloud, ExternalLink, FolderOpen, Github, GitBranch, XCircle } from "lucide-react"
6
+
7
+ import { Badge } from "@/components/ui/badge"
8
+ import type { EntityRecord } from "@/lib/types"
9
+ import {
10
+ formatGithubIssueRef,
11
+ formatProjectItemRef,
12
+ getGithubEntityProvenance,
13
+ getGithubIssueRef,
14
+ getGithubSyncState,
15
+ getProjectFieldEntries,
16
+ type GithubSyncState,
17
+ } from "@/lib/github-provenance"
18
+ import { cn } from "@/lib/utils"
19
+
20
+ interface SyncStatePillProps {
21
+ syncState?: GithubSyncState
22
+ className?: string
23
+ }
24
+
25
+ const syncStateConfig: Record<GithubSyncState, { label: string; className: string; Icon: LucideIcon }> = {
26
+ synced: {
27
+ label: "synced",
28
+ className: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
29
+ Icon: CheckCircle2,
30
+ },
31
+ diverged: {
32
+ label: "diverged",
33
+ className: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
34
+ Icon: AlertTriangle,
35
+ },
36
+ conflict: {
37
+ label: "conflict",
38
+ className: "border-destructive/40 bg-destructive/10 text-destructive",
39
+ Icon: XCircle,
40
+ },
41
+ "project-only": {
42
+ label: "project-only",
43
+ className: "border-slate-500/40 bg-slate-500/10 text-slate-700 dark:text-slate-300",
44
+ Icon: Cloud,
45
+ },
46
+ "chart-only": {
47
+ label: "chart-only",
48
+ className: "border-slate-500/40 bg-slate-500/10 text-slate-700 dark:text-slate-300",
49
+ Icon: FolderOpen,
50
+ },
51
+ }
52
+
53
+ export function SyncStatePill({ syncState, className }: SyncStatePillProps) {
54
+ if (!syncState) return null
55
+
56
+ const { label, className: stateClassName, Icon } = syncStateConfig[syncState]
57
+
58
+ return (
59
+ <Badge variant="outline" className={cn("gap-1 text-xs", stateClassName, className)}>
60
+ <Icon className="w-3 h-3" />
61
+ {label}
62
+ </Badge>
63
+ )
64
+ }
65
+
66
+ interface ProjectSourceBadgeProps {
67
+ entity: EntityRecord
68
+ className?: string
69
+ compact?: boolean
70
+ }
71
+
72
+ export function ProjectSourceBadge({ entity, className, compact = false }: ProjectSourceBadgeProps) {
73
+ const [selectedLensIndex, setSelectedLensIndex] = useState(0)
74
+ const provenance = getGithubEntityProvenance(entity)
75
+
76
+ if (!provenance.hasGithubMetadata) return null
77
+
78
+ const projectItems = provenance.projectItems
79
+ const activeLensIndex = projectItems.length > 0 ? Math.min(selectedLensIndex, projectItems.length - 1) : 0
80
+ const activeProjectItem = projectItems[activeLensIndex]
81
+ const projectRef = formatProjectItemRef(activeProjectItem)
82
+ const issueRef = formatGithubIssueRef(provenance.issue)
83
+ const projectTitle = activeProjectItem?.projectTitle
84
+ const fieldEntries = getProjectFieldEntries(entity, activeProjectItem)
85
+ const sourceLabel = getSourceLabel(provenance.source?.system, provenance.source?.toolName, provenance.lastSyncedAt)
86
+ const projectTooltip = [
87
+ projectTitle,
88
+ activeProjectItem?.itemId && `item ${activeProjectItem.itemId}`,
89
+ activeProjectItem?.projectId && `project ${activeProjectItem.projectId}`,
90
+ activeProjectItem?.url,
91
+ ]
92
+ .filter(Boolean)
93
+ .join(" · ")
94
+
95
+ return (
96
+ <div
97
+ className={cn(
98
+ "rounded-md border border-indigo-500/20 bg-indigo-500/5 p-3 text-sm",
99
+ compact && "p-2",
100
+ className,
101
+ )}
102
+ >
103
+ <div className="flex flex-wrap items-center gap-2">
104
+ {activeProjectItem?.url ? (
105
+ <a href={activeProjectItem.url} target="_blank" rel="noreferrer" title={projectTooltip}>
106
+ <ProjectBadge label={projectRef ?? "Project bridge"} />
107
+ </a>
108
+ ) : (
109
+ <ProjectBadge label={projectRef ?? "Project bridge"} title={projectTooltip} />
110
+ )}
111
+
112
+ {provenance.issue &&
113
+ (provenance.issue.url ? (
114
+ <a href={provenance.issue.url} target="_blank" rel="noreferrer" title={issueRef}>
115
+ <Badge variant="outline" className="gap-1 text-xs">
116
+ <Github className="w-3 h-3" />#{provenance.issue.number}
117
+ <ExternalLink className="w-3 h-3" />
118
+ </Badge>
119
+ </a>
120
+ ) : (
121
+ <Badge variant="outline" className="gap-1 text-xs" title={issueRef}>
122
+ <Github className="w-3 h-3" />#{provenance.issue.number}
123
+ </Badge>
124
+ ))}
125
+
126
+ <SyncStatePill syncState={provenance.syncState} />
127
+
128
+ {projectItems.length > 1 && (
129
+ <select
130
+ aria-label="Active GitHub project lens"
131
+ value={activeLensIndex}
132
+ onChange={(event) => setSelectedLensIndex(Number(event.target.value))}
133
+ className="h-7 rounded-md border border-border bg-background px-2 text-xs text-foreground"
134
+ >
135
+ {projectItems.map((item, index) => (
136
+ <option key={`${formatProjectItemRef(item) ?? item.itemId ?? "project"}-${index}`} value={index}>
137
+ {item.projectTitle ?? formatProjectItemRef(item) ?? `Project ${index + 1}`}
138
+ </option>
139
+ ))}
140
+ </select>
141
+ )}
142
+ </div>
143
+
144
+ {!compact && (projectTitle || sourceLabel || fieldEntries.length > 0) && (
145
+ <div className="mt-2 space-y-2 text-xs text-muted-foreground">
146
+ {(projectTitle || sourceLabel) && (
147
+ <div className="flex flex-wrap items-center gap-2">
148
+ {projectTitle && <span className="font-medium text-foreground">{projectTitle}</span>}
149
+ {sourceLabel && <span>{sourceLabel}</span>}
150
+ </div>
151
+ )}
152
+
153
+ {fieldEntries.length > 0 && (
154
+ <div className="flex flex-wrap gap-1.5">
155
+ {fieldEntries.map((field) => (
156
+ <span
157
+ key={field.key}
158
+ className="inline-flex max-w-full items-center gap-1 rounded-md border border-border bg-background px-2 py-1"
159
+ title={`${field.label}: ${field.value}`}
160
+ >
161
+ <span className="font-medium text-foreground">{field.label}</span>
162
+ <span className="max-w-[18rem] truncate">{field.value}</span>
163
+ </span>
164
+ ))}
165
+ </div>
166
+ )}
167
+ </div>
168
+ )}
169
+ </div>
170
+ )
171
+ }
172
+
173
+ function ProjectBadge({ label, title }: { label: string; title?: string }) {
174
+ return (
175
+ <Badge className="gap-1 bg-indigo-600 text-white hover:bg-indigo-600/90" title={title}>
176
+ <GitBranch className="w-3 h-3" />
177
+ Project: {label}
178
+ </Badge>
179
+ )
180
+ }
181
+
182
+ interface ActionStepIssueBadgeProps {
183
+ action: EntityRecord
184
+ className?: string
185
+ }
186
+
187
+ export function ActionStepIssueBadge({ action, className }: ActionStepIssueBadgeProps) {
188
+ const issue = getGithubIssueRef(action)
189
+ const syncState = getGithubSyncState(action)
190
+ const issueRef = formatGithubIssueRef(issue)
191
+
192
+ if (!issue && !syncState) return null
193
+
194
+ return (
195
+ <span className={cn("inline-flex flex-wrap items-center gap-1", className)}>
196
+ {issue &&
197
+ (issue.url ? (
198
+ <a href={issue.url} target="_blank" rel="noreferrer" title={issueRef}>
199
+ <Badge variant="outline" className="gap-1 text-xs">
200
+ <Github className="w-3 h-3" />#{issue.number}
201
+ <ExternalLink className="w-3 h-3" />
202
+ </Badge>
203
+ </a>
204
+ ) : (
205
+ <Badge variant="outline" className="gap-1 text-xs" title={issueRef}>
206
+ <Github className="w-3 h-3" />#{issue.number}
207
+ </Badge>
208
+ ))}
209
+ <SyncStatePill syncState={syncState} />
210
+ </span>
211
+ )
212
+ }
213
+
214
+ function getSourceLabel(system?: string, toolName?: string, lastSyncedAt?: string): string | undefined {
215
+ const parts = [system, toolName, formatDateTime(lastSyncedAt)].filter(Boolean)
216
+ return parts.length > 0 ? parts.join(" · ") : undefined
217
+ }
218
+
219
+ function formatDateTime(value?: string): string | undefined {
220
+ if (!value) return undefined
221
+
222
+ const date = new Date(value)
223
+ if (Number.isNaN(date.getTime())) return value
224
+
225
+ return date.toLocaleString()
226
+ }
@@ -2,8 +2,10 @@
2
2
 
3
3
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
4
4
  import { Badge } from "@/components/ui/badge"
5
- import type { ParsedData, Chart, RelationRecord } from "@/lib/types"
6
- import { ArrowRight } from "lucide-react"
5
+ import type { ParsedData, Chart, EntityRecord, RelationRecord } from "@/lib/types"
6
+ import { ArrowRight, Github } from "lucide-react"
7
+ import { getGithubBridgeRelationType, getGithubSyncState } from "@/lib/github-provenance"
8
+ import { SyncStatePill } from "./github-provenance"
7
9
 
8
10
  interface RelationGraphProps {
9
11
  chart: Chart
@@ -23,7 +25,10 @@ export function RelationGraph({ chart, data }: RelationGraphProps) {
23
25
  )
24
26
  }
25
27
 
26
- const relationsByType = relations.reduce(
28
+ const githubBridgeRelations = relations.filter((relation) => getGithubBridgeRelationType(relation))
29
+ const standardRelations = relations.filter((relation) => !getGithubBridgeRelationType(relation))
30
+
31
+ const relationsByType = standardRelations.reduce(
27
32
  (acc, rel) => {
28
33
  if (!acc[rel.relationType]) {
29
34
  acc[rel.relationType] = []
@@ -41,6 +46,28 @@ export function RelationGraph({ chart, data }: RelationGraphProps) {
41
46
  <CardDescription>Connections between chart entities</CardDescription>
42
47
  </CardHeader>
43
48
  <CardContent className="space-y-6">
49
+ {githubBridgeRelations.length > 0 && (
50
+ <div>
51
+ <h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
52
+ <Badge className="gap-1 bg-indigo-600 text-white">
53
+ <Github className="w-3 h-3" />
54
+ GitHub bridge
55
+ </Badge>
56
+ <span className="text-muted-foreground">({githubBridgeRelations.length})</span>
57
+ </h3>
58
+ <div className="space-y-2">
59
+ {githubBridgeRelations.map((rel, idx) => (
60
+ <RelationRow
61
+ key={`github-${idx}`}
62
+ relation={rel}
63
+ data={data}
64
+ bridgeLabel={getGithubBridgeRelationType(rel)}
65
+ />
66
+ ))}
67
+ </div>
68
+ </div>
69
+ )}
70
+
44
71
  {Object.entries(relationsByType).map(([type, rels]) => (
45
72
  <div key={type}>
46
73
  <h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
@@ -48,30 +75,9 @@ export function RelationGraph({ chart, data }: RelationGraphProps) {
48
75
  <span className="text-muted-foreground">({rels.length})</span>
49
76
  </h3>
50
77
  <div className="space-y-2">
51
- {rels.map((rel, idx) => {
52
- const fromEntity = data.entities.get(rel.from)
53
- const toEntity = data.entities.get(rel.to)
54
-
55
- return (
56
- <div key={idx} className="flex items-center gap-3 p-3 bg-muted/30 rounded-lg text-sm">
57
- <div className="flex-1 min-w-0">
58
- <div className="font-mono text-xs text-muted-foreground mb-1">{rel.from}</div>
59
- {fromEntity && (
60
- <div className="text-xs line-clamp-1">
61
- {fromEntity.observations[0] || fromEntity.entityType}
62
- </div>
63
- )}
64
- </div>
65
- <ArrowRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
66
- <div className="flex-1 min-w-0">
67
- <div className="font-mono text-xs text-muted-foreground mb-1">{rel.to}</div>
68
- {toEntity && (
69
- <div className="text-xs line-clamp-1">{toEntity.observations[0] || toEntity.entityType}</div>
70
- )}
71
- </div>
72
- </div>
73
- )
74
- })}
78
+ {rels.map((rel, idx) => (
79
+ <RelationRow key={idx} relation={rel} data={data} />
80
+ ))}
75
81
  </div>
76
82
  </div>
77
83
  ))}
@@ -79,3 +85,61 @@ export function RelationGraph({ chart, data }: RelationGraphProps) {
79
85
  </Card>
80
86
  )
81
87
  }
88
+
89
+ interface RelationRowProps {
90
+ relation: RelationRecord
91
+ data: ParsedData
92
+ bridgeLabel?: string
93
+ }
94
+
95
+ function RelationRow({ relation, data, bridgeLabel }: RelationRowProps) {
96
+ const fromEntity = data.entities.get(relation.from)
97
+ const toEntity = data.entities.get(relation.to)
98
+ const syncState = getGithubSyncState(relation.metadata)
99
+
100
+ return (
101
+ <div className="flex flex-col gap-2 p-3 bg-muted/30 rounded-lg text-sm sm:flex-row sm:items-center sm:gap-3">
102
+ <RelationEndpoint name={relation.from} entity={fromEntity} />
103
+
104
+ <div className="flex items-center gap-2 text-muted-foreground sm:flex-col">
105
+ <ArrowRight className="w-4 h-4 flex-shrink-0 rotate-90 sm:rotate-0" />
106
+ {(bridgeLabel || syncState) && (
107
+ <div className="flex flex-wrap justify-center gap-1">
108
+ {bridgeLabel && (
109
+ <Badge variant="outline" className="text-xs">
110
+ {bridgeLabel.replace(/_/g, " ")}
111
+ </Badge>
112
+ )}
113
+ <SyncStatePill syncState={syncState} />
114
+ </div>
115
+ )}
116
+ </div>
117
+
118
+ <RelationEndpoint name={relation.to} entity={toEntity} />
119
+ </div>
120
+ )
121
+ }
122
+
123
+ function RelationEndpoint({
124
+ name,
125
+ entity,
126
+ }: {
127
+ name: string
128
+ entity?: EntityRecord
129
+ }) {
130
+ const isGithubVirtualNode = name.startsWith("gh:")
131
+
132
+ return (
133
+ <div className="flex-1 min-w-0">
134
+ <div className="font-mono text-xs text-muted-foreground mb-1 break-all">{name}</div>
135
+ {entity ? (
136
+ <div className="text-xs line-clamp-1">{entity.observations[0] || entity.entityType}</div>
137
+ ) : isGithubVirtualNode ? (
138
+ <Badge variant="outline" className="gap-1 text-xs">
139
+ <Github className="w-3 h-3" />
140
+ GitHub issue
141
+ </Badge>
142
+ ) : null}
143
+ </div>
144
+ )
145
+ }
package/dist/cli.js CHANGED
@@ -67,7 +67,7 @@ function loadConfig(args) {
67
67
  if (args['port'] || args['p']) {
68
68
  config.port = args['port'] || args['p'];
69
69
  }
70
- if (args['no-open']) {
70
+ if (args['no-open'] || args.open === false) {
71
71
  config.noOpen = true;
72
72
  }
73
73
  if (args['live']) {
@@ -208,11 +208,13 @@ EXAMPLES:
208
208
  spawn(openCmd, [url], { detached: true, stdio: 'ignore' }).unref();
209
209
  }, 4000);
210
210
  }
211
- process.on('SIGINT', () => {
211
+ const shutdownDocker = () => {
212
212
  console.log('\n👋 Shutting down visualizer...');
213
213
  dockerProcess.kill();
214
214
  process.exit(0);
215
- });
215
+ };
216
+ process.on('SIGINT', shutdownDocker);
217
+ process.on('SIGTERM', shutdownDocker);
216
218
  dockerProcess.on('exit', (code) => { process.exit(code || 0); });
217
219
  return;
218
220
  }
@@ -253,11 +255,13 @@ EXAMPLES:
253
255
  }, 3000);
254
256
  }
255
257
  // Handle shutdown
256
- process.on('SIGINT', () => {
258
+ const shutdownLocal = () => {
257
259
  console.log('\n👋 Shutting down visualizer...');
258
260
  nextProcess.kill();
259
261
  process.exit(0);
260
- });
262
+ };
263
+ process.on('SIGINT', shutdownLocal);
264
+ process.on('SIGTERM', shutdownLocal);
261
265
  nextProcess.on('exit', (code) => {
262
266
  process.exit(code || 0);
263
267
  });
@@ -0,0 +1,20 @@
1
+ #!/bin/bash
2
+ # Build and push jgwill/coaia:visualizer to Docker Hub
3
+ # Usage: ./docker-build-push.sh [tag]
4
+ # Requires: docker login jgwill (or DOCKERHUB_TOKEN env)
5
+
6
+ set -e
7
+
8
+ TAG="${1:-visualizer}"
9
+ IMAGE="jgwill/coaia:$TAG"
10
+
11
+ echo "🐳 Building $IMAGE..."
12
+ docker build -t "$IMAGE" .
13
+
14
+ echo "📤 Pushing $IMAGE..."
15
+ docker push "$IMAGE"
16
+
17
+ echo "✅ Done: $IMAGE"
18
+ echo ""
19
+ echo "Run with:"
20
+ echo " docker run --rm -p 4321:4321 -v /abs/path/to/file.jsonl:/data/memory.jsonl $IMAGE"
@@ -0,0 +1,27 @@
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ # COAIA Visualizer Docker Entrypoint
5
+ # Serves the pre-built Next.js app with the user-supplied JSONL file.
6
+ #
7
+ # The JSONL file must be mounted into /data/ in the container.
8
+ # COAIAV_MEMORY_PATH defaults to /data/memory.jsonl but can be overridden.
9
+
10
+ MEMORY_PATH="${COAIAV_MEMORY_PATH:-/data/memory.jsonl}"
11
+ PORT="${PORT:-4321}"
12
+
13
+ # Validate that the memory file exists (warn but don't block — user may mount it later)
14
+ if [ ! -f "$MEMORY_PATH" ]; then
15
+ echo "⚠️ Warning: memory file not found at $MEMORY_PATH"
16
+ echo " Mount your JSONL file with: -v /your/file.jsonl:$MEMORY_PATH"
17
+ fi
18
+
19
+ echo "🎨 COAIA Visualizer"
20
+ echo "📁 Memory file: $MEMORY_PATH"
21
+ echo "🌐 http://localhost:$PORT"
22
+ echo ""
23
+
24
+ export COAIAV_MEMORY_PATH="$MEMORY_PATH"
25
+ export PORT="$PORT"
26
+
27
+ exec node_modules/.bin/next start -p "$PORT"