spine-framework-cortex 0.2.21 → 0.2.23

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.
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { useParams, useNavigate } from 'react-router-dom'
3
+ import { useAppPath } from '@core/hooks/useAppPath'
3
4
  import { apiFetch } from '@core/lib/api'
4
5
  import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
5
6
  import { RichTextEditor } from '@core/components/ui/RichTextEditor'
@@ -66,6 +67,7 @@ function SidebarSelect({ label, value, onChange, options }: {
66
67
  export default function KBEditorPage() {
67
68
  const { id } = useParams<{ id: string }>()
68
69
  const navigate = useNavigate()
70
+ const appPath = useAppPath()
69
71
  const isNew = !id || id === 'new'
70
72
  const [form, setForm] = useState<ArticleForm>(EMPTY)
71
73
  const [loading, setLoading] = useState(!isNew)
@@ -157,7 +159,7 @@ export default function KBEditorPage() {
157
159
  }
158
160
  }
159
161
 
160
- navigate('/cortex/kb')
162
+ navigate(appPath('/kb'))
161
163
  } catch (e: any) {
162
164
  setError(e.message)
163
165
  } finally {
@@ -184,7 +186,7 @@ export default function KBEditorPage() {
184
186
  {/* Header */}
185
187
  <div className="px-6 py-3 border-b border-border shrink-0 flex items-center justify-between">
186
188
  <div className="flex items-center gap-3">
187
- <Button variant="ghost" size="sm" className="gap-1 text-muted-foreground" onClick={() => navigate('/cortex/kb')}>
189
+ <Button variant="ghost" size="sm" className="gap-1 text-muted-foreground" onClick={() => navigate(appPath('/kb'))}>
188
190
  <ArrowLeft className="h-3.5 w-3.5" /> KB
189
191
  </Button>
190
192
  <h1 className="text-base font-semibold">{isNew ? 'New Article' : 'Edit Article'}</h1>
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect, useRef, useCallback } from 'react'
2
2
  import { useNavigate } from 'react-router-dom'
3
+ import { useAppPath } from '@core/hooks/useAppPath'
3
4
  import { apiFetch } from '@core/lib/api'
4
5
  import { RichTextEditor } from '@core/components/ui/RichTextEditor'
5
6
  import { Button } from '@core/components/ui/button'
@@ -49,6 +50,7 @@ const PRIORITY_COLORS: Record<string, string> = {
49
50
 
50
51
  export default function KBPage() {
51
52
  const navigate = useNavigate()
53
+ const appPath = useAppPath()
52
54
  const [articles, setArticles] = useState<Article[]>([])
53
55
  const [searchResults, setSearchResults] = useState<Article[] | null>(null)
54
56
  const [loading, setLoading] = useState(true)
@@ -120,7 +122,7 @@ export default function KBPage() {
120
122
  onChange={e => handleSearch(e.target.value)}
121
123
  className="flex-1 h-8"
122
124
  />
123
- <Button size="sm" className="gap-1 shrink-0" onClick={() => navigate('/cortex/kb/new')}>
125
+ <Button size="sm" className="gap-1 shrink-0" onClick={() => navigate(appPath('/kb/new'))}>
124
126
  <Plus size={14} /> New
125
127
  </Button>
126
128
  </div>
@@ -153,7 +155,7 @@ export default function KBPage() {
153
155
  <div className="p-8 text-center text-muted-foreground">
154
156
  <BookOpen className="h-10 w-10 mx-auto mb-3 opacity-30" />
155
157
  <p className="text-sm">{search ? 'No articles match.' : 'No articles yet.'}</p>
156
- <Button size="sm" variant="outline" className="mt-3 gap-1" onClick={() => navigate('/cortex/kb/new')}>
158
+ <Button size="sm" variant="outline" className="mt-3 gap-1" onClick={() => navigate(appPath('/kb/new'))}>
157
159
  <Plus size={13} /> Create article
158
160
  </Button>
159
161
  </div>
@@ -198,7 +200,7 @@ export default function KBPage() {
198
200
  <div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
199
201
  <BookOpen size={32} className="opacity-30" />
200
202
  <p className="text-sm">Select an article to preview</p>
201
- <Button size="sm" variant="outline" onClick={() => navigate('/cortex/kb/new')} className="gap-1">
203
+ <Button size="sm" variant="outline" onClick={() => navigate(appPath('/kb/new'))} className="gap-1">
202
204
  <Plus size={13} /> New article
203
205
  </Button>
204
206
  </div>
@@ -236,7 +238,7 @@ export default function KBPage() {
236
238
  size="sm"
237
239
  variant="outline"
238
240
  className="gap-1 shrink-0"
239
- onClick={() => navigate(`/cortex/kb/${selected.id}/edit`)}
241
+ onClick={() => navigate(appPath(`/kb/${selected.id}/edit`))}
240
242
  >
241
243
  <Edit size={13} /> Edit
242
244
  </Button>
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect, useCallback } from 'react'
2
2
  import { useParams, useNavigate } from 'react-router-dom'
3
+ import { useAppPath } from '@core/hooks/useAppPath'
3
4
  import { apiFetch } from '@core/lib/api'
4
5
  import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
5
6
  import { Button } from '@core/components/ui/button'
@@ -39,13 +40,19 @@ interface Ticket {
39
40
  title: string
40
41
  description?: string
41
42
  data?: {
43
+ // Case analysis fields (populated by custom_case_analysis)
44
+ ca_reported_issue?: string
45
+ ca_true_problem?: string
46
+ ca_final_solution?: string
47
+ ca_diagnostic_steps?: string[]
48
+ ca_solution_steps?: string[]
49
+ // Legacy fields
42
50
  ai_metadata?: {
43
51
  problem_statement?: string
44
52
  solution_path?: string
45
53
  }
46
- postmortem?: {
47
- kb_draft_id?: string
48
- }
54
+ // KB tracking
55
+ kb_proposed_kb_id?: string
49
56
  }
50
57
  }
51
58
 
@@ -88,6 +95,7 @@ function parseContentWithRedactions(content: string, suggestions: RedactionSugge
88
95
  export default function RedactionReview() {
89
96
  const { id } = useParams<{ id: string }>()
90
97
  const navigate = useNavigate()
98
+ const appPath = useAppPath()
91
99
  const [ticket, setTicket] = useState<Ticket | null>(null)
92
100
  const [analysis, setAnalysis] = useState<RedactionAnalysis | null>(null)
93
101
  const [loading, setLoading] = useState(true)
@@ -118,12 +126,11 @@ export default function RedactionReview() {
118
126
  setArticleTitle(`KB: ${ticketData.title}`)
119
127
  }
120
128
 
121
- // Trigger redaction analysis via AI agent
122
- const analysisRes = await apiFetch('/api/ai-agents', {
129
+ // Trigger redaction analysis via KB Redaction Agent
130
+ const analysisRes = await apiFetch('/.netlify/functions/custom_support-redaction?action=analyze', {
123
131
  method: 'POST',
124
132
  headers: { 'Content-Type': 'application/json' },
125
133
  body: JSON.stringify({
126
- action: 'run_redaction_analysis',
127
134
  ticket_id: id,
128
135
  content: buildContentForAnalysis(ticketData),
129
136
  }),
@@ -155,16 +162,33 @@ export default function RedactionReview() {
155
162
  })
156
163
  }, [id])
157
164
 
158
- // Build content from ticket for analysis
165
+ // Build content from ticket for redaction analysis.
166
+ // Prefers case analysis fields (ca_*) populated by custom_case_analysis,
167
+ // falling back to legacy ai_metadata fields.
159
168
  function buildContentForAnalysis(ticket: Ticket): string {
169
+ const d = ticket.data || {}
160
170
  const parts: string[] = []
161
171
  if (ticket.title) parts.push(`Title: ${ticket.title}`)
162
- if (ticket.description) parts.push(`Description: ${ticket.description}`)
163
- if (ticket.data?.ai_metadata?.problem_statement) {
164
- parts.push(`Problem: ${ticket.data.ai_metadata.problem_statement}`)
172
+ if (d.ca_reported_issue) {
173
+ parts.push(`Reported Issue: ${d.ca_reported_issue}`)
174
+ } else if (ticket.description) {
175
+ parts.push(`Description: ${ticket.description}`)
176
+ }
177
+ if (d.ca_true_problem) {
178
+ parts.push(`Root Cause: ${d.ca_true_problem}`)
179
+ } else if (d.ai_metadata?.problem_statement) {
180
+ parts.push(`Problem: ${d.ai_metadata.problem_statement}`)
181
+ }
182
+ if (d.ca_diagnostic_steps?.length) {
183
+ parts.push(`Diagnostic Steps:\n${d.ca_diagnostic_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`)
165
184
  }
166
- if (ticket.data?.ai_metadata?.solution_path) {
167
- parts.push(`Solution: ${ticket.data.ai_metadata.solution_path}`)
185
+ if (d.ca_solution_steps?.length) {
186
+ parts.push(`Solution Steps:\n${d.ca_solution_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`)
187
+ }
188
+ if (d.ca_final_solution) {
189
+ parts.push(`Final Solution: ${d.ca_final_solution}`)
190
+ } else if (d.ai_metadata?.solution_path) {
191
+ parts.push(`Solution: ${d.ai_metadata.solution_path}`)
168
192
  }
169
193
  return parts.join('\n\n')
170
194
  }
@@ -279,21 +303,17 @@ export default function RedactionReview() {
279
303
 
280
304
  // Update ticket with KB reference
281
305
  await apiFetch(`/api/admin-data?action=update&entity=items&id=${id}`, {
282
- method: 'POST',
306
+ method: 'PATCH',
283
307
  headers: { 'Content-Type': 'application/json' },
284
308
  body: JSON.stringify({
285
309
  data: {
286
310
  ...ticket?.data,
287
- postmortem: {
288
- ...ticket?.data?.postmortem,
289
- kb_generated: true,
290
- kb_draft_id: kbId,
291
- },
311
+ kb_proposed_kb_id: kbId,
292
312
  },
293
313
  }),
294
314
  })
295
315
 
296
- navigate(`/cortex/kb/${kbId}`)
316
+ navigate(appPath(`/kb/${kbId}`))
297
317
  }
298
318
  } finally {
299
319
  setProcessing(false)
@@ -316,7 +336,7 @@ export default function RedactionReview() {
316
336
  <AlertTriangle className="h-5 w-5" />
317
337
  <p>Failed to analyze content for redaction. Please try again.</p>
318
338
  </div>
319
- <Button onClick={() => navigate(`/cortex/support/${id}`)}>Back to Ticket</Button>
339
+ <Button onClick={() => navigate(appPath(`/support/${id}`))}>Back to Ticket</Button>
320
340
  </div>
321
341
  )
322
342
  }
@@ -327,7 +347,7 @@ export default function RedactionReview() {
327
347
  <div className="px-6 py-4 border-b border-border shrink-0">
328
348
  <div className="flex items-center justify-between">
329
349
  <div className="flex items-center gap-3">
330
- <Button variant="ghost" size="sm" onClick={() => navigate(`/cortex/support/${id}`)}>
350
+ <Button variant="ghost" size="sm" onClick={() => navigate(appPath(`/support/${id}`))}>
331
351
  <ArrowLeft className="h-4 w-4" />
332
352
  </Button>
333
353
  <div>
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState, useMemo } from 'react'
2
2
  import { useNavigate } from 'react-router-dom'
3
+ import { useAppPath } from '@core/hooks/useAppPath'
3
4
  import { apiFetch } from '@core/lib/api'
4
5
  import { useAuth } from '@core/contexts/AuthContext'
5
6
  import { Input } from '@core/components/ui/input'
@@ -105,6 +106,7 @@ const KANBAN_COLUMNS = [
105
106
 
106
107
  export default function SupportPage() {
107
108
  const navigate = useNavigate()
109
+ const appPath = useAppPath()
108
110
  const { user } = useAuth()
109
111
  const [tickets, setTickets] = useState<Ticket[]>([])
110
112
  const [loading, setLoading] = useState(true)
@@ -220,7 +222,7 @@ export default function SupportPage() {
220
222
  {sorted.map(ticket => (
221
223
  <tr
222
224
  key={ticket.id}
223
- onClick={() => navigate(`/cortex/support/${ticket.id}`)}
225
+ onClick={() => navigate(appPath(`/support/${ticket.id}`))}
224
226
  className="hover:bg-accent/50 cursor-pointer transition-colors"
225
227
  >
226
228
  <td className="px-5 py-3">
@@ -305,7 +307,7 @@ export default function SupportPage() {
305
307
  {tickets?.map(ticket => (
306
308
  <div
307
309
  key={ticket.id}
308
- onClick={() => navigate(`/cortex/support/${ticket.id}`)}
310
+ onClick={() => navigate(appPath(`/support/${ticket.id}`))}
309
311
  className="bg-background border rounded-lg p-3 cursor-pointer hover:shadow-sm transition-shadow"
310
312
  >
311
313
  <div className="flex items-start justify-between gap-2">
@@ -1,5 +1,6 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useEffect, useRef, useState } from 'react'
2
2
  import { useParams, useNavigate } from 'react-router-dom'
3
+ import { useAppPath } from '@core/hooks/useAppPath'
3
4
  import { apiFetch } from '@core/lib/api'
4
5
  import { Button } from '@core/components/ui/button'
5
6
  import { Input } from '@core/components/ui/input'
@@ -7,7 +8,8 @@ import { Badge } from '@core/components/ui/badge'
7
8
  import { Skeleton } from '@core/components/ui/skeleton'
8
9
  import { ScrollArea } from '@core/components/ui/scroll-area'
9
10
  import { Tabs, TabsList, TabsTrigger, TabsContent } from '@core/components/ui/tabs'
10
- import { ArrowLeft, Send, Lock, Globe, Bot, CheckCircle, AlertCircle, FileText, Building2, CreditCard, Package, BookOpen, Eye, EyeOff, Brain, Clock, TrendingUp, Tag as TagIcon } from 'lucide-react'
11
+ import { Textarea } from '@core/components/ui/textarea'
12
+ import { ArrowLeft, Send, Lock, Globe, Bot, CheckCircle, AlertCircle, FileText, Building2, CreditCard, Package, BookOpen, Eye, EyeOff, Brain, Clock, TrendingUp, Tag as TagIcon, Sparkles } from 'lucide-react'
11
13
  import { useAuth } from '@core/contexts/AuthContext'
12
14
 
13
15
  interface Ticket {
@@ -367,145 +369,143 @@ function CaseDataPanel({ ticket }: { ticket: Ticket | null }) {
367
369
  )
368
370
  }
369
371
 
370
- // AI Metadata Panel
371
- function AIMetadataPanel({ ticket, onGenerateKB }: { ticket: Ticket | null; onGenerateKB?: () => void }) {
372
- if (!ticket?.data?.aim_confidence_threshold && !ticket?.data?.aim_confidence_at_response && !ticket?.data?.aim_escalation_reason) {
373
- return (
374
- <div className="h-full flex flex-col">
375
- <div className="px-4 py-2 border-b border-border bg-purple-50">
376
- <p className="text-xs font-medium uppercase tracking-wide text-purple-700">AI Analysis</p>
377
- </div>
378
- <div className="flex-1 p-4 flex items-center justify-center text-sm text-muted-foreground">
379
- No AI metadata available for this ticket.
380
- </div>
381
- </div>
382
- )
383
- }
372
+ // Solution AI Panel — internal AI collaborator for support agents
373
+ interface AIMessage { role: 'user' | 'ai'; content: string }
384
374
 
385
- const ai = ticket.data
386
- const isResolved = ticket.status === 'resolved' || ticket.status === 'closed'
375
+ function SolutionAIPanel({ ticket, onGenerateKB }: { ticket: Ticket | null; onGenerateKB?: () => void }) {
376
+ const [messages, setMessages] = useState<AIMessage[]>([])
377
+ const [input, setInput] = useState('')
378
+ const [sending, setSending] = useState(false)
379
+ const [threadId, setThreadId] = useState<string | null>(null)
380
+ const bottomRef = useRef<HTMLDivElement>(null)
381
+ const ai = ticket?.data
382
+ const isResolved = ticket?.status === 'resolved' || ticket?.status === 'closed'
383
+
384
+ // Auto-scroll to bottom when new messages arrive
385
+ useEffect(() => {
386
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
387
+ }, [messages])
388
+
389
+ const sendMessage = async () => {
390
+ if (!input.trim() || !ticket?.id || sending) return
391
+ const userText = input.trim()
392
+ setInput('')
393
+ setMessages(prev => [...prev, { role: 'user', content: userText }])
394
+ setSending(true)
395
+ try {
396
+ const isFirst = !threadId
397
+ const res = await apiFetch(
398
+ `/.netlify/functions/custom_support-solution?action=${isFirst ? 'start' : 'message'}`,
399
+ {
400
+ method: 'POST',
401
+ headers: { 'Content-Type': 'application/json' },
402
+ body: JSON.stringify(isFirst
403
+ ? { ticket_id: ticket.id, message: userText }
404
+ : { thread_id: threadId, message: userText }
405
+ ),
406
+ }
407
+ ).then(r => r.json())
408
+
409
+ if (res.threadId && !threadId) setThreadId(res.threadId)
410
+ if (res.content) setMessages(prev => [...prev, { role: 'ai', content: res.content }])
411
+ } catch (err) {
412
+ setMessages(prev => [...prev, { role: 'ai', content: 'Error contacting Solution AI. Please try again.' }])
413
+ } finally {
414
+ setSending(false)
415
+ }
416
+ }
387
417
 
388
418
  return (
389
419
  <div className="h-full flex flex-col">
390
- <div className="px-4 py-2 border-b border-border bg-purple-50">
391
- <p className="text-xs font-medium uppercase tracking-wide text-purple-700">AI Analysis</p>
420
+ {/* Header */}
421
+ <div className="px-4 py-2 border-b border-border bg-purple-50 shrink-0">
422
+ <div className="flex items-center justify-between">
423
+ <div className="flex items-center gap-1.5">
424
+ <Sparkles className="h-3.5 w-3.5 text-purple-600" />
425
+ <p className="text-xs font-medium uppercase tracking-wide text-purple-700">Solution AI</p>
426
+ </div>
427
+ <p className="text-xs text-purple-500">Internal only</p>
428
+ </div>
392
429
  </div>
393
- <ScrollArea className="flex-1 p-4">
394
- <div className="space-y-4">
395
- {/* Confidence Score */}
430
+
431
+ {/* Triage metadata strip */}
432
+ {(ai?.aim_confidence_at_response || ai?.aim_escalation_reason) && (
433
+ <div className="px-3 py-1.5 border-b border-border bg-muted/40 shrink-0 flex items-center gap-3 text-xs text-muted-foreground">
396
434
  {ai.aim_confidence_at_response && (
397
- <div className="space-y-1">
398
- <div className="flex items-center justify-between text-sm">
399
- <span className="text-muted-foreground">Confidence</span>
400
- <span className={`font-mono font-medium ${ai.aim_confidence_at_response >= 0.75 ? 'text-green-600' : 'text-amber-600'}`}>
401
- {Math.round(ai.aim_confidence_at_response * 100)}%
402
- </span>
403
- </div>
404
- {ai.aim_confidence_threshold && (
405
- <p className="text-xs text-muted-foreground">Threshold: {Math.round(ai.aim_confidence_threshold * 100)}%</p>
406
- )}
407
- </div>
435
+ <span className={ai.aim_confidence_at_response >= 0.75 ? 'text-green-600' : 'text-amber-600'}>
436
+ Triage confidence: {Math.round(ai.aim_confidence_at_response * 100)}%
437
+ </span>
408
438
  )}
409
-
410
439
  {ai.aim_escalation_reason && ai.aim_escalation_reason !== 'none' && (
411
- <div className="flex items-start gap-2 p-2 bg-amber-50 rounded-lg">
412
- <AlertCircle className="h-4 w-4 text-amber-600 mt-0.5" />
413
- <div className="text-sm">
414
- <p className="font-medium text-amber-700">Escalated</p>
415
- <p className="text-amber-600 text-xs">{ai.aim_escalation_reason.replace('_', ' ')}</p>
416
- </div>
417
- </div>
418
- )}
419
-
420
- {/* Problem Statement */}
421
- {ai.aim_problem_statement && (
422
- <div className="space-y-1">
423
- <p className="text-xs font-medium text-muted-foreground uppercase">Problem</p>
424
- <p className="text-sm bg-muted p-2 rounded">{ai.aim_problem_statement}</p>
425
- </div>
440
+ <span className="text-amber-600 flex items-center gap-1">
441
+ <AlertCircle className="h-3 w-3" /> Escalated
442
+ </span>
426
443
  )}
427
-
428
- {/* Solution Path */}
429
- {ai.aim_solution_path && (
430
- <div className="space-y-1">
431
- <p className="text-xs font-medium text-muted-foreground uppercase">Solution</p>
432
- <p className="text-sm bg-muted p-2 rounded">{ai.aim_solution_path}</p>
433
- </div>
434
- )}
435
-
436
- {/* Tools Used */}
437
- {ai.aim_tools_used && ai.aim_tools_used.length > 0 && (
438
- <div className="space-y-1">
439
- <p className="text-xs font-medium text-muted-foreground uppercase">Tools Used</p>
440
- <div className="flex flex-wrap gap-1">
441
- {ai.aim_tools_used.map((tool, i) => (
442
- <Badge key={i} variant="outline" className="text-xs">{tool}</Badge>
443
- ))}
444
+ </div>
445
+ )}
446
+
447
+ {/* Chat history */}
448
+ <ScrollArea className="flex-1 px-3 py-2">
449
+ {messages.length === 0 ? (
450
+ <div className="flex flex-col items-center justify-center h-32 text-center text-muted-foreground text-xs gap-2">
451
+ <Bot className="h-6 w-6" />
452
+ <p>Ask Solution AI for help with this case.</p>
453
+ <p className="text-xs opacity-70">Searches KB · recalls similar cases · drafts replies</p>
454
+ </div>
455
+ ) : (
456
+ <div className="space-y-2">
457
+ {messages.map((msg, i) => (
458
+ <div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
459
+ <div className={`max-w-[90%] rounded-lg px-3 py-2 text-xs whitespace-pre-wrap ${
460
+ msg.role === 'user'
461
+ ? 'bg-primary text-primary-foreground'
462
+ : 'bg-muted text-foreground'
463
+ }`}>
464
+ {msg.content}
465
+ </div>
444
466
  </div>
445
- </div>
446
- )}
447
-
448
- {/* Post-mortem Section */}
449
- {isResolved && (
450
- <>
451
- <Separator />
452
- <div className="space-y-3">
453
- <p className="text-xs font-medium text-muted-foreground uppercase">Post-mortem</p>
454
-
455
- {postmortem?.root_cause_category && (
456
- <p className="text-sm">
457
- <span className="text-muted-foreground">Root cause:</span>{' '}
458
- {postmortem.root_cause_category}
459
- </p>
460
- )}
461
-
462
- {postmortem?.resolution_time_minutes && (
463
- <p className="text-sm">
464
- <span className="text-muted-foreground">Resolution time:</span>{' '}
465
- {Math.round(postmortem.resolution_time_minutes)} min
466
- </p>
467
- )}
468
-
469
- {postmortem?.customer_satisfaction !== undefined && (
470
- <div className="flex items-center gap-2">
471
- <span className="text-sm text-muted-foreground">Satisfaction:</span>
472
- <div className="flex">
473
- {[1, 2, 3, 4, 5].map((star) => (
474
- <span
475
- key={star}
476
- className={`text-lg ${star <= (postmortem.customer_satisfaction || 0) ? 'text-amber-400' : 'text-gray-300'}`}
477
- >
478
-
479
- </span>
480
- ))}
481
- </div>
482
- </div>
483
- )}
484
-
485
- {/* KB Generation */}
486
- <div className="pt-2">
487
- {postmortem?.kb_generated ? (
488
- <div className="flex items-center gap-2 text-green-600 text-sm">
489
- <CheckCircle className="h-4 w-4" />
490
- KB article created
491
- </div>
492
- ) : (
493
- <Button
494
- variant="outline"
495
- size="sm"
496
- className="w-full gap-1 text-xs"
497
- onClick={onGenerateKB}
498
- >
499
- <BookOpen className="h-3 w-3" />
500
- Generate KB Article
501
- </Button>
502
- )}
467
+ ))}
468
+ {sending && (
469
+ <div className="flex justify-start">
470
+ <div className="bg-muted rounded-lg px-3 py-2 text-xs text-muted-foreground animate-pulse">
471
+ Thinking…
503
472
  </div>
504
473
  </div>
505
- </>
474
+ )}
475
+ <div ref={bottomRef} />
476
+ </div>
477
+ )}
478
+ </ScrollArea>
479
+
480
+ {/* KB generation (resolved only) */}
481
+ {isResolved && (
482
+ <div className="px-3 py-2 border-t border-border shrink-0">
483
+ {ai?.kb_proposed_kb_id ? (
484
+ <div className="flex items-center gap-2 text-green-600 text-xs">
485
+ <CheckCircle className="h-3.5 w-3.5" /> KB article created
486
+ </div>
487
+ ) : (
488
+ <Button variant="outline" size="sm" className="w-full gap-1 text-xs" onClick={onGenerateKB}>
489
+ <BookOpen className="h-3 w-3" /> Generate KB Article
490
+ </Button>
506
491
  )}
507
492
  </div>
508
- </ScrollArea>
493
+ )}
494
+
495
+ {/* Input */}
496
+ <div className="px-3 py-2 border-t border-border shrink-0 flex gap-2">
497
+ <Textarea
498
+ value={input}
499
+ onChange={e => setInput(e.target.value)}
500
+ onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() } }}
501
+ placeholder="Ask Solution AI…"
502
+ className="flex-1 text-xs resize-none min-h-[36px] max-h-[100px]"
503
+ rows={1}
504
+ />
505
+ <Button size="sm" onClick={sendMessage} disabled={!input.trim() || sending} className="shrink-0">
506
+ <Send className="h-3.5 w-3.5" />
507
+ </Button>
508
+ </div>
509
509
  </div>
510
510
  )
511
511
  }
@@ -729,6 +729,7 @@ function Separator() {
729
729
  export default function TicketDetailPage() {
730
730
  const { id } = useParams<{ id: string }>()
731
731
  const navigate = useNavigate()
732
+ const appPath = useAppPath()
732
733
  const { user } = useAuth()
733
734
  const [ticket, setTicket] = useState<Ticket | null>(null)
734
735
  const [threads, setThreads] = useState<Thread[]>([])
@@ -838,8 +839,7 @@ export default function TicketDetailPage() {
838
839
  }
839
840
 
840
841
  const handleGenerateKB = () => {
841
- // Trigger KB generation flow - would navigate to redaction review
842
- navigate(`/cortex/support/${id}/kb-review`)
842
+ navigate(appPath(`/support/${id}/kb-review`))
843
843
  }
844
844
 
845
845
  const externalThread = threads.find(t => !t.visibility || t.visibility === 'external') ?? null
@@ -852,7 +852,7 @@ export default function TicketDetailPage() {
852
852
  <div className="flex flex-col h-full">
853
853
  <div className="px-6 py-4 border-b border-border shrink-0 flex items-center justify-between">
854
854
  <div className="flex items-center gap-3">
855
- <Button variant="ghost" size="sm" className="gap-1 text-muted-foreground" onClick={() => navigate('/cortex/support')}>
855
+ <Button variant="ghost" size="sm" className="gap-1 text-muted-foreground" onClick={() => navigate(appPath('/support'))}>
856
856
  <ArrowLeft className="h-3.5 w-3.5" /> Support
857
857
  </Button>
858
858
  <div>
@@ -910,7 +910,7 @@ export default function TicketDetailPage() {
910
910
  </TabsList>
911
911
  </Tabs>
912
912
  {activeSidePanel === 'case' && <CaseDataPanel ticket={ticket} />}
913
- {activeSidePanel === 'ai' && <AIMetadataPanel ticket={ticket} onGenerateKB={handleGenerateKB} />}
913
+ {activeSidePanel === 'ai' && <SolutionAIPanel ticket={ticket} onGenerateKB={handleGenerateKB} />}
914
914
  {activeSidePanel === 'analysis' && <CaseAnalysisPanel ticket={ticket} analysisLoading={analysisLoading} />}
915
915
  </div>
916
916
  </div>