shraga 0.0.3 → 0.1.2
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/README.md +82 -27
- package/defaults/agents/summarizer.md +16 -0
- package/defaults/agents/trace-extractor.md +84 -0
- package/defaults/bin/claude +45 -0
- package/defaults/bin/claude-revive +17 -0
- package/defaults/extensions/README.md +70 -0
- package/defaults/extensions/selftest.ext.ts +43 -0
- package/defaults/extensions/stripe-webhook.ext.ts +58 -0
- package/defaults/gmail-triage-prompt.md +42 -0
- package/defaults/scripts/README +4 -0
- package/defaults/scripts/agent-once.ts +67 -0
- package/defaults/scripts/backfill-slack-usernames.ts +82 -0
- package/defaults/scripts/notifier-throttle.ts +44 -0
- package/defaults/scripts/summarize-conversations.ts +5 -0
- package/defaults/shraga.config.ts +29 -0
- package/defaults/skills/add-skill.md +14 -0
- package/defaults/skills/artifacts.md +116 -0
- package/defaults/skills/code-review.md +26 -0
- package/defaults/skills/communications.md +54 -0
- package/defaults/skills/context-audit.md +87 -0
- package/defaults/skills/debug.md +10 -0
- package/defaults/skills/garden.md +179 -0
- package/defaults/skills/github-contributor.md +35 -0
- package/defaults/skills/identity.md +30 -0
- package/defaults/skills/mcp-server.md +62 -0
- package/defaults/skills/mcps-sync.md +105 -0
- package/defaults/skills/plan.md +9 -0
- package/defaults/skills/platform.md +177 -0
- package/defaults/skills/reconcile.md +239 -0
- package/defaults/skills/scheduler.md +192 -0
- package/defaults/skills/self-aware.md +136 -0
- package/defaults/skills/shraga-know.md +333 -0
- package/defaults/skills/stripe.md +55 -0
- package/defaults/skills/write-tests.md +10 -0
- package/defaults/skills-defaults.json +1 -0
- package/defaults/system-prompt.md +46 -0
- package/defaults/workspace/context.md +28 -0
- package/defaults/workspace.md +50 -0
- package/defaults/zdotdir/.gitignore +8 -0
- package/defaults/zdotdir/.zlogin +3 -0
- package/defaults/zdotdir/.zprofile +1 -0
- package/defaults/zdotdir/.zshenv +4 -0
- package/defaults/zdotdir/.zshrc +3 -0
- package/dist/client/assets/index-BoHttkMt.js +1940 -0
- package/dist/client/assets/index-DdibEb2O.css +10 -0
- package/dist/client/index.html +22 -0
- package/package.json +59 -14
- package/src/cli.ts +71 -46
- package/src/client/App.tsx +510 -0
- package/src/client/components/ArtifactCard.tsx +26 -0
- package/src/client/components/ArtifactPanel.tsx +138 -0
- package/src/client/components/AuthedImage.tsx +85 -0
- package/src/client/components/AutocompleteTextarea.tsx +149 -0
- package/src/client/components/ChatView.tsx +866 -0
- package/src/client/components/CliAuthConsent.tsx +98 -0
- package/src/client/components/ConfigPanel.tsx +328 -0
- package/src/client/components/ConversationHeader.tsx +156 -0
- package/src/client/components/ConversationPane.tsx +277 -0
- package/src/client/components/LoginPage.tsx +81 -0
- package/src/client/components/MachineStats.tsx +77 -0
- package/src/client/components/McpManager.tsx +209 -0
- package/src/client/components/MessageInput.tsx +263 -0
- package/src/client/components/OAuthConsent.tsx +103 -0
- package/src/client/components/SchedulesManager.tsx +99 -0
- package/src/client/components/Sidebar.tsx +235 -0
- package/src/client/components/SkillsManager.tsx +280 -0
- package/src/client/components/SmartChart.tsx +167 -0
- package/src/client/components/Toast.tsx +54 -0
- package/src/client/components/WorkspaceTree.tsx +313 -0
- package/src/client/components/ZoomableImage.tsx +123 -0
- package/src/client/components/artifact-presets.ts +10 -0
- package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
- package/src/client/components/schedules/ScheduleList.tsx +271 -0
- package/src/client/components/ui/accordion.tsx +50 -0
- package/src/client/components/ui/button.tsx +43 -0
- package/src/client/components/ui/dialog.tsx +82 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/scroll-area.tsx +39 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/globals.css +51 -0
- package/src/client/hooks/useAgentSocket.ts +79 -0
- package/src/client/hooks/useArtifacts.ts +89 -0
- package/src/client/hooks/useAuth.ts +127 -0
- package/src/client/hooks/useConversation.ts +412 -0
- package/src/client/hooks/useDarkMode.ts +57 -0
- package/src/client/hooks/useIsMobile.ts +23 -0
- package/src/client/hooks/usePush.ts +127 -0
- package/src/client/hooks/useSchedules.ts +73 -0
- package/src/client/hooks/useUnread.ts +238 -0
- package/src/client/lib/desktopAttention.ts +75 -0
- package/src/client/lib/firebase.ts +32 -0
- package/src/client/lib/googleAuthNative.ts +94 -0
- package/src/client/lib/native.ts +43 -0
- package/src/client/lib/schedule-types.ts +34 -0
- package/src/client/lib/sessionApi.ts +58 -0
- package/src/client/lib/slots.tsx +79 -0
- package/src/client/lib/storage.ts +39 -0
- package/src/client/lib/utils.ts +26 -0
- package/src/client/lib/workspaceContext.tsx +54 -0
- package/src/client/lib/ws.ts +203 -0
- package/src/client/main.tsx +14 -0
- package/src/mcp-stdio-bridge.ts +70 -0
- package/src/scripts/summarize-conversations.ts +5 -0
- package/src/scripts/typecheck.ts +43 -0
- package/src/server/agents.ts +54 -0
- package/src/server/api-keys.ts +63 -0
- package/src/server/artifacts/artifacts.export.ts +85 -0
- package/src/server/artifacts/artifacts.handler.ts +93 -0
- package/src/server/artifacts/artifacts.routes.ts +43 -0
- package/src/server/artifacts/artifacts.service.ts +100 -0
- package/src/server/artifacts/artifacts.types.ts +31 -0
- package/src/server/auth.ts +262 -0
- package/src/server/claude.ts +394 -0
- package/src/server/commands.ts +21 -0
- package/src/server/contacts.ts +177 -0
- package/src/server/conversation-summarizer.ts +204 -0
- package/src/server/data-sync.ts +664 -0
- package/src/server/directives.ts +91 -0
- package/src/server/engine/claude-code.ts +514 -0
- package/src/server/engine/index.ts +41 -0
- package/src/server/engine/registry.ts +21 -0
- package/src/server/engine/shared.ts +47 -0
- package/src/server/engine/types.ts +48 -0
- package/src/server/env-resolve.ts +71 -0
- package/src/server/env-sanitize.ts +9 -0
- package/src/server/events/bus.ts +29 -0
- package/src/server/events/dispatcher.ts +48 -0
- package/src/server/events/routes.ts +19 -0
- package/src/server/events/types.ts +9 -0
- package/src/server/extensions.ts +101 -0
- package/src/server/features.ts +109 -0
- package/src/server/file-inject.ts +45 -0
- package/src/server/hooks.ts +142 -0
- package/src/server/idempotency.ts +25 -0
- package/src/server/index.ts +1715 -0
- package/src/server/integrity-audit.ts +132 -0
- package/src/server/mcp-catalog.ts +70 -0
- package/src/server/mcp-oauth.ts +198 -0
- package/src/server/mcp-progress.ts +45 -0
- package/src/server/mcp-server.ts +456 -0
- package/src/server/mcp-sidecar.ts +87 -0
- package/src/server/mcp.ts +291 -0
- package/src/server/model-aliases.ts +76 -0
- package/src/server/paths.ts +24 -0
- package/src/server/polls.ts +175 -0
- package/src/server/push/apns.ts +113 -0
- package/src/server/push/fcm.ts +108 -0
- package/src/server/push/push.ts +66 -0
- package/src/server/push/store.ts +84 -0
- package/src/server/push/triggers.ts +99 -0
- package/src/server/scheduler/builtins.ts +157 -0
- package/src/server/scheduler/engine.ts +432 -0
- package/src/server/scheduler/index.ts +4 -0
- package/src/server/scheduler/runner.ts +334 -0
- package/src/server/scheduler/storage.ts +98 -0
- package/src/server/scheduler/timing.ts +70 -0
- package/src/server/scheduler/types.ts +62 -0
- package/src/server/sdk-utils.ts +45 -0
- package/src/server/seed.ts +174 -0
- package/src/server/session-bus.ts +18 -0
- package/src/server/sessions.ts +559 -0
- package/src/server/shraga-config.ts +167 -0
- package/src/server/skills.ts +372 -0
- package/src/server/slack/api.ts +37 -0
- package/src/server/slack/bot.ts +391 -0
- package/src/server/slack/context-cache.ts +42 -0
- package/src/server/slack/feature.ts +59 -0
- package/src/server/slack/mention-rewrite.ts +59 -0
- package/src/server/slack/oauth.ts +102 -0
- package/src/server/slack/questions.ts +112 -0
- package/src/server/slack/sessions.ts +139 -0
- package/src/server/stats.ts +106 -0
- package/src/server/summarize.ts +11 -0
- package/src/server/turn-context.ts +61 -0
- package/src/server/unclaw-config.ts +19 -0
- package/src/server/unread.ts +79 -0
- package/src/server/user-context.ts +33 -0
- package/src/server/vendor-sync.ts +52 -0
- package/src/server/voice-provider.ts +74 -0
- package/src/server/workspace.ts +249 -0
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import ReactMarkdown from 'react-markdown';
|
|
3
|
+
import remarkGfm from 'remark-gfm';
|
|
4
|
+
import rehypeHighlight from 'rehype-highlight';
|
|
5
|
+
import type { Root, Element } from 'hast';
|
|
6
|
+
import type { Plugin } from 'unified';
|
|
7
|
+
|
|
8
|
+
const RTL_BLOCK_TAGS = new Set(['p', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'td', 'th']);
|
|
9
|
+
const rehypeBidi: Plugin<[], Root> = () => (tree) => {
|
|
10
|
+
const visit = (node: Root | Element) => {
|
|
11
|
+
for (const child of (node.children ?? [])) {
|
|
12
|
+
if (child.type === 'element') {
|
|
13
|
+
if (RTL_BLOCK_TAGS.has(child.tagName)) {
|
|
14
|
+
child.properties ??= {};
|
|
15
|
+
child.properties.dir = 'auto';
|
|
16
|
+
}
|
|
17
|
+
visit(child);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
visit(tree);
|
|
22
|
+
};
|
|
23
|
+
import 'highlight.js/styles/github.css';
|
|
24
|
+
import { ChevronRight, Wrench, User, Bot, Copy, Check, RotateCcw, Pencil, X, SendHorizontal, ShieldQuestion, CheckCircle2, XCircle, Eye, EyeOff, Info, Loader2, BrainCircuit, GitFork, Minimize2 } from 'lucide-react';
|
|
25
|
+
import { cn } from '@/lib/utils';
|
|
26
|
+
import type { ChatMessage, MessageBlock, Attachment } from '@/hooks/useConversation';
|
|
27
|
+
import type { AskQuestion, QuestionAnswers } from '@/lib/ws';
|
|
28
|
+
import { SmartChart, tryParseChartData } from './SmartChart';
|
|
29
|
+
import { AuthedImage, AuthedFileLink } from './AuthedImage';
|
|
30
|
+
import { useSlots } from '@/lib/slots';
|
|
31
|
+
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
|
32
|
+
import { ZoomableImage } from './ZoomableImage';
|
|
33
|
+
|
|
34
|
+
// The appwrap iOS WKWebView wedges its GPU compositor when rendering react-markdown's DOM for a chat with
|
|
35
|
+
// history — JS keeps running but paint + hit-testing freeze for ~1-2min (screen stuck/touch-dead) before
|
|
36
|
+
// WebKit lazily restores. The SAME content in mobile Safari and on desktop is fine, so this is specific to
|
|
37
|
+
// the native WKWebView shell, not the web app. Until the native shell issue is fixed, render assistant
|
|
38
|
+
// messages as PLAIN TEXT inside the native app only; full markdown stays on Safari/desktop/web.
|
|
39
|
+
const IS_APPWRAP_NATIVE = typeof window !== 'undefined' && !!(window as { webkit?: { messageHandlers?: { appwrap?: unknown } } }).webkit?.messageHandlers?.appwrap;
|
|
40
|
+
|
|
41
|
+
function AssistantMarkdown({ text, onImageClick }: { text: string; onImageClick?: (src: string) => void }) {
|
|
42
|
+
const clean = text.replace(/\[Image #\d+\]\s*/g, '').trim();
|
|
43
|
+
if (IS_APPWRAP_NATIVE) {
|
|
44
|
+
return <div className="prose prose-sm max-w-none dark:prose-invert break-words min-w-0 whitespace-pre-wrap">{clean}</div>;
|
|
45
|
+
}
|
|
46
|
+
return (
|
|
47
|
+
<ReactMarkdown
|
|
48
|
+
remarkPlugins={[remarkGfm]}
|
|
49
|
+
rehypePlugins={[rehypeHighlight, rehypeBidi]}
|
|
50
|
+
className="prose prose-sm max-w-none dark:prose-invert prose-pre:bg-muted prose-pre:border prose-code:before:content-none prose-code:after:content-none break-words min-w-0"
|
|
51
|
+
components={{
|
|
52
|
+
pre: ({ children, ...props }) => <CodeBlock {...props}>{children}</CodeBlock>,
|
|
53
|
+
a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>,
|
|
54
|
+
img: ({ src, alt }) => <AuthedImage src={src ?? ''} alt={alt ?? ''} className="max-h-[80vh] max-w-full rounded-xl border object-contain cursor-pointer hover:opacity-80 transition-opacity" onClick={(s) => onImageClick?.(s)} />,
|
|
55
|
+
}}
|
|
56
|
+
>{clean}</ReactMarkdown>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatTs(ts: number): string {
|
|
61
|
+
const d = new Date(ts);
|
|
62
|
+
const now = new Date();
|
|
63
|
+
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
64
|
+
if (d.toDateString() === now.toDateString()) return time;
|
|
65
|
+
const sameYear = d.getFullYear() === now.getFullYear();
|
|
66
|
+
const date = d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(!sameYear && { year: 'numeric' }) });
|
|
67
|
+
return `${date}, ${time}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface Props {
|
|
71
|
+
messages: ChatMessage[];
|
|
72
|
+
busy: boolean;
|
|
73
|
+
connectionStatus?: 'disconnected' | 'reconnected' | 'update_available' | 'server_restarting' | null;
|
|
74
|
+
onPermissionRespond?: (id: string, allow: boolean, allowAll?: boolean) => void;
|
|
75
|
+
onQuestionRespond?: (id: string, answers: QuestionAnswers) => void;
|
|
76
|
+
onReplay?: (messageId: string, text: string, attachments?: Attachment[]) => void;
|
|
77
|
+
onEdit?: (messageId: string, text: string, attachments?: Attachment[]) => void;
|
|
78
|
+
onFork?: (messageIndex: number) => void;
|
|
79
|
+
multiParticipant?: boolean;
|
|
80
|
+
/** Opaque add-on status items — rendered inline at the bottom of the thread via the statusChips slot. */
|
|
81
|
+
statusItems?: unknown[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const SHOW_DETAILS_KEY = 'shraga:showDetails';
|
|
85
|
+
|
|
86
|
+
export function ChatView({ messages, busy, connectionStatus, onPermissionRespond, onQuestionRespond, onReplay, onEdit, onFork, multiParticipant, statusItems }: Props) {
|
|
87
|
+
const slots = useSlots();
|
|
88
|
+
const bottomRef = useRef<HTMLDivElement>(null);
|
|
89
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
90
|
+
const isNearBottom = useRef(true);
|
|
91
|
+
const [showDetails, setShowDetails] = useState(() => localStorage.getItem(SHOW_DETAILS_KEY) === 'true');
|
|
92
|
+
const [lightbox, setLightbox] = useState<string | null>(null);
|
|
93
|
+
|
|
94
|
+
const handleScroll = () => {
|
|
95
|
+
const el = scrollRef.current;
|
|
96
|
+
if (!el) return;
|
|
97
|
+
isNearBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const screenMap = useMemo(() => buildScreenMap(messages), [messages]);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (isNearBottom.current) {
|
|
104
|
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
105
|
+
}
|
|
106
|
+
}, [messages, busy]);
|
|
107
|
+
|
|
108
|
+
if (messages.length === 0 && !busy) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex-1 flex items-center justify-center">
|
|
111
|
+
<div className="text-center space-y-3 max-w-md px-4">
|
|
112
|
+
<div className="mx-auto w-12 h-12 rounded-full bg-muted flex items-center justify-center">
|
|
113
|
+
<Bot className="w-6 h-6 text-muted-foreground" />
|
|
114
|
+
</div>
|
|
115
|
+
<h2 className="text-lg font-medium">How can I help?</h2>
|
|
116
|
+
<p className="text-sm text-muted-foreground">
|
|
117
|
+
I can read files, edit code, run commands, search the web, and use any configured MCP tools on the remote machine.
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const toggleDetails = () => {
|
|
125
|
+
setShowDetails((v) => {
|
|
126
|
+
localStorage.setItem(SHOW_DETAILS_KEY, String(!v));
|
|
127
|
+
return !v;
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto overflow-x-hidden">
|
|
133
|
+
<div className="max-w-3xl mx-auto px-2 sm:px-4 py-4 sm:py-6 space-y-1">
|
|
134
|
+
<div className="flex justify-end mb-2">
|
|
135
|
+
<button onClick={toggleDetails} className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" title={showDetails ? 'Hide tool details' : 'Show tool details'}>
|
|
136
|
+
{showDetails ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
|
137
|
+
Details
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
{messages.map((msg, idx) => (
|
|
141
|
+
<MessageRow key={msg.id} message={msg} messageIndex={idx} isLast={idx === messages.length - 1} showDetails={showDetails} onPermissionRespond={onPermissionRespond} onQuestionRespond={onQuestionRespond} onReplay={onReplay} onEdit={onEdit} onFork={onFork} onImageClick={setLightbox} busy={busy && idx === messages.length - 1} screenMap={screenMap} multiParticipant={multiParticipant} />
|
|
142
|
+
))}
|
|
143
|
+
|
|
144
|
+
{busy && messages[messages.length - 1]?.role !== 'assistant' && (
|
|
145
|
+
<div className="flex gap-3 py-4">
|
|
146
|
+
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
|
|
147
|
+
<Bot className="w-4 h-4 text-muted-foreground" />
|
|
148
|
+
</div>
|
|
149
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
150
|
+
<span className="inline-flex gap-1">
|
|
151
|
+
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
152
|
+
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
153
|
+
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{!!statusItems?.length && slots.statusChips && (
|
|
160
|
+
<div className="flex gap-3 py-2">
|
|
161
|
+
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
|
|
162
|
+
<Bot className="w-4 h-4 text-muted-foreground" />
|
|
163
|
+
</div>
|
|
164
|
+
<div className="flex-1 min-w-0 pt-1">{slots.statusChips(statusItems)}</div>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{connectionStatus && connectionStatus !== 'update_available' && (
|
|
169
|
+
<div className={cn(
|
|
170
|
+
'text-center text-xs py-1.5 rounded-md transition-opacity',
|
|
171
|
+
connectionStatus === 'reconnected' ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'
|
|
172
|
+
)}>
|
|
173
|
+
{connectionStatus === 'server_restarting' ? 'Server updating — reconnecting automatically…'
|
|
174
|
+
: connectionStatus === 'disconnected' ? 'Connection lost — reconnecting...'
|
|
175
|
+
: 'Reconnected.'}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
<div ref={bottomRef} />
|
|
180
|
+
</div>
|
|
181
|
+
<Dialog open={!!lightbox} onOpenChange={(o) => { if (!o) setLightbox(null); }}>
|
|
182
|
+
<DialogContent className="max-w-[90vw] max-h-[90vh] w-[90vw] h-[90vh] p-2 flex items-center justify-center bg-background/95">
|
|
183
|
+
{lightbox && <ZoomableImage src={lightbox} />}
|
|
184
|
+
</DialogContent>
|
|
185
|
+
</Dialog>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function MessageRow({
|
|
191
|
+
message,
|
|
192
|
+
messageIndex,
|
|
193
|
+
isLast,
|
|
194
|
+
showDetails,
|
|
195
|
+
onPermissionRespond,
|
|
196
|
+
onQuestionRespond,
|
|
197
|
+
onReplay,
|
|
198
|
+
onEdit,
|
|
199
|
+
onFork,
|
|
200
|
+
onImageClick,
|
|
201
|
+
busy,
|
|
202
|
+
screenMap,
|
|
203
|
+
multiParticipant,
|
|
204
|
+
}: {
|
|
205
|
+
message: ChatMessage;
|
|
206
|
+
messageIndex: number;
|
|
207
|
+
isLast: boolean;
|
|
208
|
+
showDetails: boolean;
|
|
209
|
+
onPermissionRespond?: (id: string, allow: boolean, allowAll?: boolean) => void;
|
|
210
|
+
onQuestionRespond?: (id: string, answers: QuestionAnswers) => void;
|
|
211
|
+
onReplay?: (messageId: string, text: string, attachments?: Attachment[]) => void;
|
|
212
|
+
onEdit?: (messageId: string, text: string, attachments?: Attachment[]) => void;
|
|
213
|
+
onFork?: (messageIndex: number) => void;
|
|
214
|
+
onImageClick?: (src: string) => void;
|
|
215
|
+
busy?: boolean;
|
|
216
|
+
screenMap?: Map<string, string[]>;
|
|
217
|
+
multiParticipant?: boolean;
|
|
218
|
+
}) {
|
|
219
|
+
const compactBlock = message.blocks.find((b) => b.type === 'compact_marker');
|
|
220
|
+
if (compactBlock && compactBlock.type === 'compact_marker') {
|
|
221
|
+
return <CompactMarkerDivider summary={compactBlock.summary} compactedCount={compactBlock.compactedCount} />;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const isUser = message.role === 'user';
|
|
225
|
+
const userText = isUser
|
|
226
|
+
? message.blocks.find((b) => b.type === 'text')?.text ?? ''
|
|
227
|
+
: '';
|
|
228
|
+
const userAttachments: Attachment[] | undefined = isUser
|
|
229
|
+
? message.blocks
|
|
230
|
+
.filter((b): b is MessageBlock & { type: 'image' | 'file' } => b.type === 'image' || b.type === 'file')
|
|
231
|
+
.map((b) => b.type === 'image'
|
|
232
|
+
? { url: b.src, name: b.src.split('/').pop() ?? 'image', mimeType: 'image/png', path: b.src }
|
|
233
|
+
: { url: b.src, name: b.name, mimeType: b.mimeType, path: b.src })
|
|
234
|
+
: undefined;
|
|
235
|
+
const [editing, setEditing] = useState(false);
|
|
236
|
+
const [draft, setDraft] = useState('');
|
|
237
|
+
const [actionsVisible, setActionsVisible] = useState(false);
|
|
238
|
+
const taRef = useRef<HTMLTextAreaElement>(null);
|
|
239
|
+
|
|
240
|
+
const startEdit = () => {
|
|
241
|
+
setDraft(userText);
|
|
242
|
+
setEditing(true);
|
|
243
|
+
setTimeout(() => {
|
|
244
|
+
const el = taRef.current;
|
|
245
|
+
if (!el) return;
|
|
246
|
+
el.style.height = 'auto';
|
|
247
|
+
el.style.height = el.scrollHeight + 'px';
|
|
248
|
+
el.focus();
|
|
249
|
+
el.setSelectionRange(el.value.length, el.value.length);
|
|
250
|
+
}, 0);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const submitEdit = () => {
|
|
254
|
+
const trimmed = draft.trim();
|
|
255
|
+
if (!trimmed) return;
|
|
256
|
+
setEditing(false);
|
|
257
|
+
onEdit?.(message.id, trimmed, userAttachments?.length ? userAttachments : undefined);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div
|
|
262
|
+
className={cn('flex gap-3 py-4 group/row', isUser && 'flex-row-reverse')}
|
|
263
|
+
onTouchStart={(e) => { if (!(e.target as HTMLElement).closest('button')) setActionsVisible((v) => !v); }}
|
|
264
|
+
>
|
|
265
|
+
{!isUser && (
|
|
266
|
+
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
|
267
|
+
<Bot className="w-4 h-4 text-primary" />
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
{isUser && (
|
|
271
|
+
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
|
|
272
|
+
<User className="w-4 h-4 text-muted-foreground" />
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
<div className={cn('min-w-0 max-w-[92%] sm:max-w-[85%] space-y-2')}>
|
|
277
|
+
{multiParticipant && isUser && message.senderName && (
|
|
278
|
+
<div className="text-[11px] font-medium text-muted-foreground text-right px-1">{message.senderName}</div>
|
|
279
|
+
)}
|
|
280
|
+
{editing ? (
|
|
281
|
+
<div className="inline-block text-left w-full">
|
|
282
|
+
<textarea
|
|
283
|
+
ref={taRef}
|
|
284
|
+
value={draft}
|
|
285
|
+
onChange={(e) => {
|
|
286
|
+
setDraft(e.target.value);
|
|
287
|
+
e.target.style.height = 'auto';
|
|
288
|
+
e.target.style.height = e.target.scrollHeight + 'px';
|
|
289
|
+
}}
|
|
290
|
+
onKeyDown={(e) => {
|
|
291
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitEdit(); }
|
|
292
|
+
if (e.key === 'Escape') setEditing(false);
|
|
293
|
+
}}
|
|
294
|
+
className="w-full rounded-xl border bg-muted/50 px-4 py-2.5 text-sm resize-none outline-none focus:ring-1 focus:ring-primary min-h-[40px]"
|
|
295
|
+
/>
|
|
296
|
+
<div className="flex gap-1 justify-end mt-1">
|
|
297
|
+
<button onClick={() => setEditing(false)} className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" title="Cancel">
|
|
298
|
+
<X className="w-3.5 h-3.5" />
|
|
299
|
+
</button>
|
|
300
|
+
<button onClick={submitEdit} className="p-1 rounded-md text-primary hover:bg-primary/10 transition-colors" title="Send">
|
|
301
|
+
<SendHorizontal className="w-3.5 h-3.5" />
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
) : (
|
|
306
|
+
<>
|
|
307
|
+
{message.blocks
|
|
308
|
+
.filter((b) => {
|
|
309
|
+
if (showDetails) return true;
|
|
310
|
+
if (b.type === 'tool_use' && b.tool === 'AskUserQuestion') {
|
|
311
|
+
return message.blocks.some((r) => r.type === 'tool_result' && r.toolUseId === b.toolUseId);
|
|
312
|
+
}
|
|
313
|
+
return b.type !== 'tool_use' && b.type !== 'tool_result' && b.type !== 'thinking';
|
|
314
|
+
})
|
|
315
|
+
.filter((b) => {
|
|
316
|
+
if (b.type !== 'tool_result') return true;
|
|
317
|
+
return !message.blocks.some((u) => u.type === 'tool_use' && u.toolUseId === b.toolUseId);
|
|
318
|
+
})
|
|
319
|
+
.map((block, i, arr) => {
|
|
320
|
+
const result = block.type === 'tool_use'
|
|
321
|
+
? message.blocks.find((b): b is MessageBlock & { type: 'tool_result' } => b.type === 'tool_result' && b.toolUseId === block.toolUseId)
|
|
322
|
+
: undefined;
|
|
323
|
+
return <BlockRenderer key={i} block={block} isUser={isUser} onPermissionRespond={onPermissionRespond} onQuestionRespond={onQuestionRespond} pairedResult={result} onImageClick={onImageClick} busy={busy} screenMap={screenMap} />;
|
|
324
|
+
})}
|
|
325
|
+
{isUser && userText && (
|
|
326
|
+
<div className={cn('flex gap-1 items-center justify-end transition-opacity', actionsVisible ? 'opacity-100' : 'opacity-0 group-hover/row:opacity-100')}>
|
|
327
|
+
{message.ts && <span className="text-[10px] text-muted-foreground/50 select-none mr-1">{formatTs(message.ts)}</span>}
|
|
328
|
+
<button
|
|
329
|
+
onClick={() => onReplay?.(message.id, userText, userAttachments?.length ? userAttachments : undefined)}
|
|
330
|
+
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
331
|
+
title="Replay"
|
|
332
|
+
>
|
|
333
|
+
<RotateCcw className="w-3.5 h-3.5" />
|
|
334
|
+
</button>
|
|
335
|
+
<button
|
|
336
|
+
onClick={startEdit}
|
|
337
|
+
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
338
|
+
title="Edit & resend"
|
|
339
|
+
>
|
|
340
|
+
<Pencil className="w-3.5 h-3.5" />
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
{!isUser && !(busy && isLast) && (
|
|
345
|
+
<div className={cn('flex gap-1 items-center transition-opacity', actionsVisible ? 'opacity-100' : 'opacity-0 group-hover/row:opacity-100')}>
|
|
346
|
+
{message.ts && <span className="text-[10px] text-muted-foreground/40 select-none mr-1">{formatTs(message.ts)}</span>}
|
|
347
|
+
<button
|
|
348
|
+
onClick={() => onFork?.(messageIndex)}
|
|
349
|
+
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
350
|
+
title="Fork from here"
|
|
351
|
+
>
|
|
352
|
+
<GitFork className="w-3.5 h-3.5" />
|
|
353
|
+
</button>
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
356
|
+
</>
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function CompactMarkerDivider({ summary, compactedCount }: { summary: string; compactedCount: number }) {
|
|
364
|
+
const [expanded, setExpanded] = useState(false);
|
|
365
|
+
return (
|
|
366
|
+
<div className="py-3">
|
|
367
|
+
<div className="flex items-center gap-3">
|
|
368
|
+
<div className="flex-1 border-t border-amber-500/30" />
|
|
369
|
+
<button
|
|
370
|
+
onClick={() => setExpanded(!expanded)}
|
|
371
|
+
className="flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 hover:bg-amber-500/20 transition-colors"
|
|
372
|
+
>
|
|
373
|
+
<Minimize2 className="w-3 h-3" />
|
|
374
|
+
Compacted · {compactedCount} messages
|
|
375
|
+
<ChevronRight className={cn('w-3 h-3 transition-transform', expanded && 'rotate-90')} />
|
|
376
|
+
</button>
|
|
377
|
+
<div className="flex-1 border-t border-amber-500/30" />
|
|
378
|
+
</div>
|
|
379
|
+
{expanded && (
|
|
380
|
+
<div className="mt-2 mx-auto max-w-2xl px-4 py-3 rounded-lg border border-amber-500/20 bg-amber-500/5 text-xs text-muted-foreground whitespace-pre-wrap">
|
|
381
|
+
{summary}
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function BlockRenderer({ block, isUser, onPermissionRespond, onQuestionRespond, pairedResult, onImageClick, busy, screenMap }: { block: MessageBlock; isUser: boolean; onPermissionRespond?: (id: string, allow: boolean, allowAll?: boolean) => void; onQuestionRespond?: (id: string, answers: QuestionAnswers) => void; pairedResult?: MessageBlock & { type: 'tool_result' }; onImageClick?: (src: string) => void; busy?: boolean; screenMap?: Map<string, string[]> }) {
|
|
389
|
+
const slots = useSlots();
|
|
390
|
+
if (block.type === 'image') {
|
|
391
|
+
return (
|
|
392
|
+
<AuthedImage src={block.src} className="max-h-[80vh] max-w-full rounded-xl border object-contain cursor-pointer hover:opacity-80 transition-opacity" onClick={(s) => onImageClick?.(s)} />
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (block.type === 'file') {
|
|
397
|
+
return (
|
|
398
|
+
<div>
|
|
399
|
+
<AuthedFileLink src={block.src} name={block.name} className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border bg-muted/50 text-sm text-muted-foreground hover:bg-muted transition-colors" />
|
|
400
|
+
</div>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (block.type === 'context') {
|
|
405
|
+
return <ContextBlock label={block.label} text={block.text} />;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (block.type === 'thinking') {
|
|
409
|
+
return <ThinkingBlock text={block.text} />;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (block.type === 'text') {
|
|
413
|
+
if (isUser) {
|
|
414
|
+
return (
|
|
415
|
+
<div dir="auto" className="inline-block bg-primary text-primary-foreground rounded-2xl rounded-tr-sm px-4 py-2.5 text-sm break-words min-w-0 max-w-full whitespace-pre-wrap">
|
|
416
|
+
{block.text}
|
|
417
|
+
</div>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
return (
|
|
421
|
+
<div className="text-sm" dir="auto">
|
|
422
|
+
<AssistantMarkdown text={block.text} onImageClick={onImageClick} />
|
|
423
|
+
</div>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (block.type === 'tool_use') {
|
|
428
|
+
if (block.tool === 'AskUserQuestion' && pairedResult?.output) {
|
|
429
|
+
return <AskUserQuestionSummary input={block.input} result={pairedResult.output} />;
|
|
430
|
+
}
|
|
431
|
+
return <ToolUseBlock block={block} tool={block.tool} input={block.input} result={pairedResult?.output} busy={busy} screenMap={screenMap} />;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (block.type === 'tool_result') {
|
|
435
|
+
return <ToolResultBlock output={block.output} />;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (block.type === 'permission_request') {
|
|
439
|
+
return <PermissionRequestBlock id={block.id} tool={block.tool} input={block.input} onRespond={onPermissionRespond} />;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (block.type === 'question_request') {
|
|
443
|
+
return <QuestionRequestBlock id={block.id} questions={block.questions} onRespond={onQuestionRespond} />;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Non-core (extension) block type — the core renders nothing itself; an add-on slot may.
|
|
447
|
+
return slots.extensionBlocks?.({ block, onImageClick }) ?? null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function ContextBlock({ label, text }: { label: string; text: string }) {
|
|
451
|
+
const [expanded, setExpanded] = useState(false);
|
|
452
|
+
return (
|
|
453
|
+
<div className="border border-blue-500/20 rounded-lg overflow-hidden text-xs bg-blue-500/5">
|
|
454
|
+
<button
|
|
455
|
+
onClick={() => setExpanded(!expanded)}
|
|
456
|
+
className="flex items-center gap-1.5 w-full px-3 py-2 hover:bg-blue-500/10 transition-colors text-left"
|
|
457
|
+
>
|
|
458
|
+
<Info className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
|
459
|
+
<span className="text-blue-300 font-medium">{label}</span>
|
|
460
|
+
<ChevronRight className={cn('w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0 ml-auto', expanded && 'rotate-90')} />
|
|
461
|
+
</button>
|
|
462
|
+
{expanded && (
|
|
463
|
+
<div className="px-3 py-2 border-t border-blue-500/20 text-muted-foreground whitespace-pre-wrap">
|
|
464
|
+
{text}
|
|
465
|
+
</div>
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function ThinkingBlock({ text }: { text: string }) {
|
|
472
|
+
const [expanded, setExpanded] = useState(false);
|
|
473
|
+
const preview = text.slice(0, 120).replace(/\n/g, ' ');
|
|
474
|
+
return (
|
|
475
|
+
<div className="border border-violet-500/20 rounded-lg overflow-hidden text-xs bg-violet-500/5">
|
|
476
|
+
<button
|
|
477
|
+
onClick={() => setExpanded(!expanded)}
|
|
478
|
+
className="flex items-center gap-1.5 w-full px-3 py-2 hover:bg-violet-500/10 transition-colors text-left"
|
|
479
|
+
>
|
|
480
|
+
<BrainCircuit className="w-3.5 h-3.5 text-violet-400 shrink-0" />
|
|
481
|
+
<span className="text-violet-300 font-medium">Thinking</span>
|
|
482
|
+
<span className="text-muted-foreground truncate">{preview}</span>
|
|
483
|
+
<ChevronRight className={cn('w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0 ml-auto', expanded && 'rotate-90')} />
|
|
484
|
+
</button>
|
|
485
|
+
{expanded && (
|
|
486
|
+
<div className="px-3 py-2 border-t border-violet-500/20 text-muted-foreground whitespace-pre-wrap">
|
|
487
|
+
{text}
|
|
488
|
+
</div>
|
|
489
|
+
)}
|
|
490
|
+
</div>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function buildScreenMap(messages: ChatMessage[]): Map<string, string[]> {
|
|
495
|
+
const map = new Map<string, string[]>();
|
|
496
|
+
for (const msg of messages) {
|
|
497
|
+
for (const block of msg.blocks) {
|
|
498
|
+
if (block.type !== 'tool_result' || !block.output) continue;
|
|
499
|
+
try {
|
|
500
|
+
const parsed = JSON.parse(block.output);
|
|
501
|
+
if (typeof parsed?.sessionId === 'string' && Array.isArray(parsed?.screen)) {
|
|
502
|
+
map.set(parsed.sessionId, parsed.screen);
|
|
503
|
+
}
|
|
504
|
+
} catch {}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return map;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function parseAnswersFromResult(result: string): Record<string, string> {
|
|
511
|
+
const answers: Record<string, string> = {};
|
|
512
|
+
const re = /"([^"]+)"="([^"]+)"/g;
|
|
513
|
+
let match;
|
|
514
|
+
while ((match = re.exec(result)) !== null) {
|
|
515
|
+
answers[match[1]] = match[2];
|
|
516
|
+
}
|
|
517
|
+
return answers;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function AskUserQuestionSummary({ input, result }: { input: unknown; result: string }) {
|
|
521
|
+
const data = input as { questions?: Array<{ question: string; header?: string }> };
|
|
522
|
+
const questions = data?.questions ?? [];
|
|
523
|
+
const answers = parseAnswersFromResult(result);
|
|
524
|
+
if (!questions.length || !Object.keys(answers).length) return null;
|
|
525
|
+
|
|
526
|
+
return (
|
|
527
|
+
<div className="border rounded-lg overflow-hidden text-xs bg-blue-500/5 border-blue-500/20">
|
|
528
|
+
<div className="flex items-center gap-1.5 px-2 sm:px-3 py-2 bg-blue-50 dark:bg-blue-950/40">
|
|
529
|
+
<ShieldQuestion className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
|
530
|
+
<span className="font-medium text-blue-300">Questions answered</span>
|
|
531
|
+
</div>
|
|
532
|
+
<div className="px-2 sm:px-3 py-2 space-y-2">
|
|
533
|
+
{questions.map((q, i) => {
|
|
534
|
+
const answer = answers[q.question];
|
|
535
|
+
if (!answer) return null;
|
|
536
|
+
return (
|
|
537
|
+
<div key={i} className="space-y-0.5">
|
|
538
|
+
<div className="text-muted-foreground">{q.header ? `${q.header}: ` : ''}{q.question}</div>
|
|
539
|
+
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-300 font-medium">
|
|
540
|
+
<Check className="w-2.5 h-2.5" />{answer}
|
|
541
|
+
</span>
|
|
542
|
+
</div>
|
|
543
|
+
);
|
|
544
|
+
})}
|
|
545
|
+
</div>
|
|
546
|
+
</div>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function ToolUseBlock({ block, tool, input, result, busy, screenMap }: { block: MessageBlock; tool: string; input: unknown; result?: string; busy?: boolean; screenMap?: Map<string, string[]> }) {
|
|
551
|
+
const slots = useSlots();
|
|
552
|
+
const [expanded, setExpanded] = useState(false);
|
|
553
|
+
const inputStr = typeof input === 'string' ? input : JSON.stringify(input, null, 2);
|
|
554
|
+
const isEmpty = typeof input === 'object' && input !== null && Object.keys(input as object).length === 0;
|
|
555
|
+
const preview = isEmpty
|
|
556
|
+
? ''
|
|
557
|
+
: typeof input === 'object' && input !== null
|
|
558
|
+
? Object.keys(input as object).join(', ')
|
|
559
|
+
: String(input).slice(0, 60);
|
|
560
|
+
|
|
561
|
+
const resultTrimmed = result != null ? stripLineNumbers(result.trim().replace(/\[Image #\d+\]\s*/g, '').trim()) : undefined;
|
|
562
|
+
const hasResult = result != null;
|
|
563
|
+
const isError = resultTrimmed ? /^(Error|ERROR)|"status"\s*:\s*[45]\d\d|ENOENT|EACCES|Permission denied|command not found|No such file/i.test(resultTrimmed) : false;
|
|
564
|
+
const statusIcon = hasResult
|
|
565
|
+
? isError ? <XCircle className="w-3 h-3 text-red-400 shrink-0" /> : <Check className="w-3 h-3 text-green-400 shrink-0" />
|
|
566
|
+
: null;
|
|
567
|
+
|
|
568
|
+
return (
|
|
569
|
+
<div className="text-xs min-w-0 space-y-2">
|
|
570
|
+
<div className="border rounded-lg overflow-hidden">
|
|
571
|
+
<button
|
|
572
|
+
onClick={() => setExpanded(!expanded)}
|
|
573
|
+
className="flex items-center gap-1.5 w-full px-2 sm:px-3 py-2 bg-muted/50 hover:bg-muted transition-colors text-left min-w-0"
|
|
574
|
+
>
|
|
575
|
+
<Wrench className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
|
576
|
+
<span className="font-medium text-foreground shrink-0">{tool}</span>
|
|
577
|
+
{!isEmpty && <span className="text-muted-foreground truncate min-w-0">{preview}</span>}
|
|
578
|
+
{hasResult ? statusIcon : busy ? <Loader2 className="w-3 h-3 text-muted-foreground animate-spin shrink-0" /> : <span title="Interrupted"><XCircle className="w-3 h-3 text-amber-400 shrink-0" /></span>}
|
|
579
|
+
<ChevronRight className={cn('w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0 ml-auto', expanded && 'rotate-90')} />
|
|
580
|
+
</button>
|
|
581
|
+
{expanded && (
|
|
582
|
+
<div className="border-t">
|
|
583
|
+
{!isEmpty && (
|
|
584
|
+
<pre className="p-3 overflow-auto max-h-64 text-xs bg-background whitespace-pre-wrap break-all">
|
|
585
|
+
{inputStr}
|
|
586
|
+
</pre>
|
|
587
|
+
)}
|
|
588
|
+
{resultTrimmed && (
|
|
589
|
+
<pre className={cn('p-3 overflow-auto max-h-64 text-xs whitespace-pre-wrap break-words', !isEmpty && 'border-t', isError ? 'bg-red-50 dark:bg-red-950/20 text-red-700 dark:text-red-300' : 'bg-muted/30 text-foreground/80')}>
|
|
590
|
+
{resultTrimmed}
|
|
591
|
+
</pre>
|
|
592
|
+
)}
|
|
593
|
+
</div>
|
|
594
|
+
)}
|
|
595
|
+
</div>
|
|
596
|
+
{slots.toolRenderers?.({ block, result, screens: screenMap })}
|
|
597
|
+
</div>
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Strip cat -n line-number prefixes (e.g. " 1\tcode here") */
|
|
602
|
+
function stripLineNumbers(text: string): string {
|
|
603
|
+
const lines = text.split('\n');
|
|
604
|
+
if (lines.length < 2) return text;
|
|
605
|
+
const numbered = lines.filter(l => l.length > 0).every(l => /^\s*\d+\t/.test(l));
|
|
606
|
+
if (!numbered) return text;
|
|
607
|
+
return lines.map(l => l.replace(/^\s*\d+\t/, '')).join('\n');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function ToolResultBlock({ output }: { output: string }) {
|
|
611
|
+
const [expanded, setExpanded] = useState(false);
|
|
612
|
+
const cleaned = output.trim().replace(/\[Image #\d+\]\s*/g, '').trim();
|
|
613
|
+
const trimmed = stripLineNumbers(cleaned);
|
|
614
|
+
|
|
615
|
+
if (!trimmed) return null;
|
|
616
|
+
|
|
617
|
+
const isError = /^(Error|ERROR)|"status"\s*:\s*[45]\d\d|ENOENT|EACCES|Permission denied|command not found|No such file/i.test(trimmed);
|
|
618
|
+
const chartData = !isError ? tryParseChartData(trimmed) : null;
|
|
619
|
+
const isLong = trimmed.length > 200;
|
|
620
|
+
const preview = trimmed.slice(0, 120).replace(/\n/g, ' ');
|
|
621
|
+
|
|
622
|
+
const Icon = isError ? XCircle : Check;
|
|
623
|
+
const borderCls = isError ? 'border-red-200 dark:border-red-900' : 'border-green-200 dark:border-green-900';
|
|
624
|
+
const bgCls = isError
|
|
625
|
+
? 'bg-red-50 dark:bg-red-950/30 hover:bg-red-100 dark:hover:bg-red-950/50'
|
|
626
|
+
: 'bg-green-50 dark:bg-green-950/30 hover:bg-green-100 dark:hover:bg-green-950/50';
|
|
627
|
+
const iconCls = isError ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400';
|
|
628
|
+
|
|
629
|
+
return (
|
|
630
|
+
<div className={cn('border rounded-lg overflow-hidden text-xs min-w-0', borderCls)}>
|
|
631
|
+
<button
|
|
632
|
+
onClick={() => setExpanded(!expanded)}
|
|
633
|
+
className={cn('flex items-center gap-1.5 w-full px-2 sm:px-3 py-2 transition-colors text-left min-w-0', bgCls)}
|
|
634
|
+
>
|
|
635
|
+
<Icon className={cn('w-3.5 h-3.5 shrink-0', iconCls)} />
|
|
636
|
+
<span className="text-muted-foreground truncate flex-1 min-w-0">{isLong ? preview + '…' : preview}</span>
|
|
637
|
+
<ChevronRight className={cn('w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0', expanded && 'rotate-90')} />
|
|
638
|
+
</button>
|
|
639
|
+
{expanded && (
|
|
640
|
+
<div className="p-3 bg-background border-t">
|
|
641
|
+
{chartData ? <SmartChart data={chartData} /> : (
|
|
642
|
+
<pre className="overflow-auto max-h-96 text-xs whitespace-pre-wrap break-words">{trimmed}</pre>
|
|
643
|
+
)}
|
|
644
|
+
</div>
|
|
645
|
+
)}
|
|
646
|
+
</div>
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function PermissionRequestBlock({
|
|
651
|
+
id,
|
|
652
|
+
tool,
|
|
653
|
+
input,
|
|
654
|
+
onRespond,
|
|
655
|
+
}: {
|
|
656
|
+
id: string;
|
|
657
|
+
tool: string;
|
|
658
|
+
input: Record<string, unknown>;
|
|
659
|
+
onRespond?: (id: string, allow: boolean, allowAll?: boolean) => void;
|
|
660
|
+
}) {
|
|
661
|
+
const [expanded, setExpanded] = useState(false);
|
|
662
|
+
const inputStr = JSON.stringify(input, null, 2);
|
|
663
|
+
const preview = Object.keys(input).join(', ');
|
|
664
|
+
|
|
665
|
+
return (
|
|
666
|
+
<div className="border-2 rounded-lg overflow-hidden text-xs border-amber-300 dark:border-amber-700 animate-in fade-in slide-in-from-bottom-2 min-w-0">
|
|
667
|
+
<div className="flex items-center gap-1.5 px-2 sm:px-3 py-2 bg-amber-50 dark:bg-amber-950/40 min-w-0">
|
|
668
|
+
<ShieldQuestion className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0" />
|
|
669
|
+
<span className="font-medium text-foreground shrink-0">{tool}</span>
|
|
670
|
+
<button onClick={() => setExpanded(!expanded)} className="text-muted-foreground truncate min-w-0 text-left hover:text-foreground transition-colors">
|
|
671
|
+
{preview}
|
|
672
|
+
<ChevronRight className={cn('w-3.5 h-3.5 inline ml-1 transition-transform', expanded && 'rotate-90')} />
|
|
673
|
+
</button>
|
|
674
|
+
</div>
|
|
675
|
+
{expanded && (
|
|
676
|
+
<pre className="p-3 overflow-auto max-h-48 text-xs bg-background border-t border-amber-200 dark:border-amber-800 whitespace-pre-wrap break-all">
|
|
677
|
+
{inputStr}
|
|
678
|
+
</pre>
|
|
679
|
+
)}
|
|
680
|
+
<div className="flex flex-wrap gap-2 px-2 sm:px-3 py-2 bg-amber-50/50 dark:bg-amber-950/20 border-t border-amber-200 dark:border-amber-800">
|
|
681
|
+
<button
|
|
682
|
+
onClick={() => onRespond?.(id, true)}
|
|
683
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium bg-green-600 text-white hover:bg-green-700 transition-colors"
|
|
684
|
+
>
|
|
685
|
+
<CheckCircle2 className="w-3.5 h-3.5" />
|
|
686
|
+
Allow
|
|
687
|
+
</button>
|
|
688
|
+
<button
|
|
689
|
+
onClick={() => onRespond?.(id, true, true)}
|
|
690
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium border border-green-600 text-green-700 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-950/30 transition-colors"
|
|
691
|
+
>
|
|
692
|
+
<CheckCircle2 className="w-3.5 h-3.5" />
|
|
693
|
+
Always Allow
|
|
694
|
+
</button>
|
|
695
|
+
<button
|
|
696
|
+
onClick={() => onRespond?.(id, false)}
|
|
697
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium bg-red-600 text-white hover:bg-red-700 transition-colors"
|
|
698
|
+
>
|
|
699
|
+
<XCircle className="w-3.5 h-3.5" />
|
|
700
|
+
Deny
|
|
701
|
+
</button>
|
|
702
|
+
</div>
|
|
703
|
+
</div>
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function QuestionRequestBlock({
|
|
708
|
+
id,
|
|
709
|
+
questions,
|
|
710
|
+
onRespond,
|
|
711
|
+
}: {
|
|
712
|
+
id: string;
|
|
713
|
+
questions: AskQuestion[];
|
|
714
|
+
onRespond?: (id: string, answers: QuestionAnswers) => void;
|
|
715
|
+
}) {
|
|
716
|
+
// selections[qIdx] = Set of chosen labels; "other" free-text kept separately.
|
|
717
|
+
const [selections, setSelections] = useState<Record<number, Set<string>>>({});
|
|
718
|
+
const [other, setOther] = useState<Record<number, string>>({});
|
|
719
|
+
const [submitted, setSubmitted] = useState(false);
|
|
720
|
+
const slots = useSlots();
|
|
721
|
+
|
|
722
|
+
// Reflect externally-supplied answers into the card as they arrive (add-on slot; no-op in core).
|
|
723
|
+
useEffect(() => {
|
|
724
|
+
return slots.onCardInteraction?.({
|
|
725
|
+
id,
|
|
726
|
+
questions,
|
|
727
|
+
onAnswers: (answers) => {
|
|
728
|
+
setSelections(() => {
|
|
729
|
+
const next: Record<number, Set<string>> = {};
|
|
730
|
+
questions.forEach((q, i) => {
|
|
731
|
+
const a = answers[q.question];
|
|
732
|
+
if (a == null) return;
|
|
733
|
+
next[i] = new Set(Array.isArray(a) ? a : [a]);
|
|
734
|
+
});
|
|
735
|
+
return next;
|
|
736
|
+
});
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
}, [id, questions, slots]);
|
|
740
|
+
|
|
741
|
+
const toggle = (qIdx: number, label: string, multi: boolean) => {
|
|
742
|
+
setSelections((prev) => {
|
|
743
|
+
const cur = new Set(prev[qIdx] ?? []);
|
|
744
|
+
if (multi) { cur.has(label) ? cur.delete(label) : cur.add(label); }
|
|
745
|
+
else { cur.clear(); cur.add(label); }
|
|
746
|
+
return { ...prev, [qIdx]: cur };
|
|
747
|
+
});
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
const OTHER = '__other__';
|
|
751
|
+
const answeredCount = questions.filter((q, i) => {
|
|
752
|
+
const sel = selections[i];
|
|
753
|
+
if (!sel || sel.size === 0) return false;
|
|
754
|
+
if (sel.has(OTHER) && !other[i]?.trim()) return false;
|
|
755
|
+
return true;
|
|
756
|
+
}).length;
|
|
757
|
+
const ready = answeredCount === questions.length;
|
|
758
|
+
|
|
759
|
+
const submit = () => {
|
|
760
|
+
if (!ready || submitted) return;
|
|
761
|
+
const answers: QuestionAnswers = {};
|
|
762
|
+
questions.forEach((q, i) => {
|
|
763
|
+
const labels = [...(selections[i] ?? [])].map((l) => (l === OTHER ? other[i].trim() : l));
|
|
764
|
+
answers[q.question] = q.multiSelect ? labels : labels[0];
|
|
765
|
+
});
|
|
766
|
+
setSubmitted(true);
|
|
767
|
+
onRespond?.(id, answers);
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
return (
|
|
771
|
+
<div className="border-2 rounded-lg overflow-hidden text-xs border-blue-300 dark:border-blue-700 animate-in fade-in slide-in-from-bottom-2 min-w-0">
|
|
772
|
+
<div className="flex items-center gap-1.5 px-2 sm:px-3 py-2 bg-blue-50 dark:bg-blue-950/40">
|
|
773
|
+
<ShieldQuestion className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0" />
|
|
774
|
+
<span className="font-medium text-foreground">Questions for you</span>
|
|
775
|
+
</div>
|
|
776
|
+
<div className="px-2 sm:px-3 py-2 space-y-4 bg-background">
|
|
777
|
+
{questions.map((q, i) => {
|
|
778
|
+
const multi = !!q.multiSelect;
|
|
779
|
+
const sel = selections[i] ?? new Set<string>();
|
|
780
|
+
return (
|
|
781
|
+
<div key={i} className="space-y-1.5">
|
|
782
|
+
<div className="font-medium text-foreground">{q.question}</div>
|
|
783
|
+
<div className="space-y-1">
|
|
784
|
+
{q.options.map((opt) => (
|
|
785
|
+
<button
|
|
786
|
+
key={opt.label}
|
|
787
|
+
disabled={submitted}
|
|
788
|
+
onClick={() => toggle(i, opt.label, multi)}
|
|
789
|
+
className={cn(
|
|
790
|
+
'flex items-start gap-2 w-full text-left px-2 py-1.5 rounded-md border transition-colors',
|
|
791
|
+
sel.has(opt.label)
|
|
792
|
+
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/40'
|
|
793
|
+
: 'border-border hover:bg-muted/50'
|
|
794
|
+
)}
|
|
795
|
+
>
|
|
796
|
+
<span className={cn('mt-0.5 w-3.5 h-3.5 shrink-0 border flex items-center justify-center', multi ? 'rounded-sm' : 'rounded-full', sel.has(opt.label) ? 'bg-blue-500 border-blue-500' : 'border-muted-foreground')}>
|
|
797
|
+
{sel.has(opt.label) && <Check className="w-2.5 h-2.5 text-white" />}
|
|
798
|
+
</span>
|
|
799
|
+
<span className="min-w-0">
|
|
800
|
+
<span className="font-medium text-foreground">{opt.label}</span>
|
|
801
|
+
{opt.description && <span className="text-muted-foreground"> — {opt.description}</span>}
|
|
802
|
+
</span>
|
|
803
|
+
</button>
|
|
804
|
+
))}
|
|
805
|
+
<div className={cn('flex items-center gap-2 px-2 py-1.5 rounded-md border', sel.has(OTHER) ? 'border-blue-500 bg-blue-50 dark:bg-blue-950/40' : 'border-border')}>
|
|
806
|
+
<button disabled={submitted} onClick={() => toggle(i, OTHER, multi)} className={cn('w-3.5 h-3.5 shrink-0 border flex items-center justify-center', multi ? 'rounded-sm' : 'rounded-full', sel.has(OTHER) ? 'bg-blue-500 border-blue-500' : 'border-muted-foreground')}>
|
|
807
|
+
{sel.has(OTHER) && <Check className="w-2.5 h-2.5 text-white" />}
|
|
808
|
+
</button>
|
|
809
|
+
<input
|
|
810
|
+
type="text"
|
|
811
|
+
disabled={submitted}
|
|
812
|
+
value={other[i] ?? ''}
|
|
813
|
+
placeholder="Other…"
|
|
814
|
+
onChange={(e) => { setOther((p) => ({ ...p, [i]: e.target.value })); if (e.target.value && !sel.has(OTHER)) toggle(i, OTHER, multi); }}
|
|
815
|
+
className="flex-1 bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
|
|
816
|
+
/>
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
</div>
|
|
820
|
+
);
|
|
821
|
+
})}
|
|
822
|
+
</div>
|
|
823
|
+
<div className="flex items-center gap-2 px-2 sm:px-3 py-2 bg-blue-50/50 dark:bg-blue-950/20 border-t border-blue-200 dark:border-blue-800">
|
|
824
|
+
<button
|
|
825
|
+
onClick={submit}
|
|
826
|
+
disabled={!ready || submitted}
|
|
827
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
828
|
+
>
|
|
829
|
+
<SendHorizontal className="w-3.5 h-3.5" />
|
|
830
|
+
{submitted ? 'Sent' : 'Submit'}
|
|
831
|
+
</button>
|
|
832
|
+
<span className="text-muted-foreground">{answeredCount}/{questions.length} answered</span>
|
|
833
|
+
</div>
|
|
834
|
+
</div>
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function CodeBlock({ children, ...props }: any) {
|
|
839
|
+
const ref = useRef<HTMLPreElement>(null);
|
|
840
|
+
return (
|
|
841
|
+
<div className="relative group">
|
|
842
|
+
<pre ref={ref} {...props} className="rounded-lg border bg-muted p-4 overflow-x-auto text-xs text-foreground">
|
|
843
|
+
{children}
|
|
844
|
+
</pre>
|
|
845
|
+
<CopyButton getText={() => ref.current?.textContent || ''} />
|
|
846
|
+
</div>
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function CopyButton({ getText }: { getText: () => string }) {
|
|
851
|
+
const [copied, setCopied] = useState(false);
|
|
852
|
+
|
|
853
|
+
return (
|
|
854
|
+
<button
|
|
855
|
+
onClick={() => {
|
|
856
|
+
navigator.clipboard.writeText(getText());
|
|
857
|
+
setCopied(true);
|
|
858
|
+
setTimeout(() => setCopied(false), 2000);
|
|
859
|
+
}}
|
|
860
|
+
className="absolute top-2 right-2 p-1.5 rounded-md bg-background/80 border opacity-0 group-hover:opacity-100 transition-opacity"
|
|
861
|
+
title="Copy"
|
|
862
|
+
>
|
|
863
|
+
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
|
864
|
+
</button>
|
|
865
|
+
);
|
|
866
|
+
}
|