openrune 1.1.2 → 2.0.0
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.ko.md +5 -76
- package/README.md +7 -80
- package/bin/rune.js +18 -653
- package/package.json +10 -40
- package/.claude-plugin/marketplace.json +0 -17
- package/.claude-plugin/plugin.json +0 -24
- package/.mcp.json +0 -16
- package/bootstrap.js +0 -8
- package/channel/rune-channel.ts +0 -486
- package/electron-builder.yml +0 -61
- package/finder-extension/FinderSync.swift +0 -47
- package/finder-extension/RuneFinderSync.appex/Contents/Info.plist +0 -27
- package/finder-extension/RuneFinderSync.appex/Contents/MacOS/RuneFinderSync +0 -0
- package/finder-extension/main.swift +0 -5
- package/renderer/index.html +0 -12
- package/renderer/src/App.tsx +0 -44
- package/renderer/src/features/chat/activity-block.tsx +0 -152
- package/renderer/src/features/chat/chat-header.tsx +0 -58
- package/renderer/src/features/chat/chat-input.tsx +0 -190
- package/renderer/src/features/chat/chat-panel.tsx +0 -151
- package/renderer/src/features/chat/markdown-renderer.tsx +0 -26
- package/renderer/src/features/chat/message-bubble.tsx +0 -79
- package/renderer/src/features/chat/message-list.tsx +0 -178
- package/renderer/src/features/chat/types.ts +0 -32
- package/renderer/src/features/chat/use-chat.ts +0 -260
- package/renderer/src/features/terminal/terminal-panel.tsx +0 -155
- package/renderer/src/global.d.ts +0 -29
- package/renderer/src/globals.css +0 -92
- package/renderer/src/hooks/use-ipc.ts +0 -24
- package/renderer/src/lib/markdown.ts +0 -83
- package/renderer/src/lib/utils.ts +0 -6
- package/renderer/src/main.tsx +0 -10
- package/renderer/tsconfig.json +0 -16
- package/renderer/vite.config.ts +0 -23
- package/screenshot-chatting-ui.png +0 -0
- package/src/main.ts +0 -796
- package/src/preload.ts +0 -58
- package/tsconfig.json +0 -14
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import Cocoa
|
|
2
|
-
import FinderSync
|
|
3
|
-
|
|
4
|
-
class RuneFinderSync: FIFinderSync {
|
|
5
|
-
override init() {
|
|
6
|
-
super.init()
|
|
7
|
-
// Monitor all directories — menu appears everywhere in Finder
|
|
8
|
-
FIFinderSyncController.default().directoryURLs = [URL(fileURLWithPath: "/")]
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
override func menu(for menuKind: FIMenuKind) -> NSMenu {
|
|
12
|
-
let menu = NSMenu(title: "Rune")
|
|
13
|
-
|
|
14
|
-
// contextualMenuForContainer = background right-click (empty space)
|
|
15
|
-
// contextualMenuForItems = right-click on selected items
|
|
16
|
-
if menuKind == .contextualMenuForContainer || menuKind == .contextualMenuForItems {
|
|
17
|
-
let item = NSMenuItem(
|
|
18
|
-
title: "New Rune",
|
|
19
|
-
action: #selector(createRune(_:)),
|
|
20
|
-
keyEquivalent: ""
|
|
21
|
-
)
|
|
22
|
-
if #available(macOS 11.0, *) {
|
|
23
|
-
item.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Rune")
|
|
24
|
-
}
|
|
25
|
-
menu.addItem(item)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return menu
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
@objc func createRune(_ sender: AnyObject?) {
|
|
32
|
-
guard let target = FIFinderSyncController.default().targetedURL() else { return }
|
|
33
|
-
let dirPath = target.path
|
|
34
|
-
|
|
35
|
-
// Use shell to create .rune file and open it
|
|
36
|
-
let script = """
|
|
37
|
-
cd "\(dirPath)" && \
|
|
38
|
-
/usr/local/bin/rune new agent 2>/dev/null || \
|
|
39
|
-
~/.rune/create-rune.sh "\(dirPath)" 2>/dev/null
|
|
40
|
-
"""
|
|
41
|
-
|
|
42
|
-
let task = Process()
|
|
43
|
-
task.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
44
|
-
task.arguments = ["-c", script]
|
|
45
|
-
try? task.run()
|
|
46
|
-
}
|
|
47
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
-
<plist version="1.0">
|
|
4
|
-
<dict>
|
|
5
|
-
<key>CFBundleIdentifier</key>
|
|
6
|
-
<string>com.studio-h.rune.finder-sync</string>
|
|
7
|
-
<key>CFBundleName</key>
|
|
8
|
-
<string>RuneFinderSync</string>
|
|
9
|
-
<key>CFBundleDisplayName</key>
|
|
10
|
-
<string>Rune Finder Extension</string>
|
|
11
|
-
<key>CFBundleExecutable</key>
|
|
12
|
-
<string>RuneFinderSync</string>
|
|
13
|
-
<key>CFBundlePackageType</key>
|
|
14
|
-
<string>XPC!</string>
|
|
15
|
-
<key>CFBundleVersion</key>
|
|
16
|
-
<string>0.1.0</string>
|
|
17
|
-
<key>CFBundleShortVersionString</key>
|
|
18
|
-
<string>0.1.0</string>
|
|
19
|
-
<key>NSExtension</key>
|
|
20
|
-
<dict>
|
|
21
|
-
<key>NSExtensionPointIdentifier</key>
|
|
22
|
-
<string>com.apple.FinderSync</string>
|
|
23
|
-
<key>NSExtensionPrincipalClass</key>
|
|
24
|
-
<string>RuneFinderSync.RuneFinderSync</string>
|
|
25
|
-
</dict>
|
|
26
|
-
</dict>
|
|
27
|
-
</plist>
|
|
Binary file
|
package/renderer/index.html
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="ko" class="dark">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>Rune</title>
|
|
7
|
-
</head>
|
|
8
|
-
<body class="bg-background text-foreground overflow-hidden">
|
|
9
|
-
<div id="root"></div>
|
|
10
|
-
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
-
</body>
|
|
12
|
-
</html>
|
package/renderer/src/App.tsx
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
-
import { Bot } from 'lucide-react'
|
|
3
|
-
import { ChatPanel } from './features/chat/chat-panel'
|
|
4
|
-
import { useChat } from './features/chat/use-chat'
|
|
5
|
-
|
|
6
|
-
export function App() {
|
|
7
|
-
const chat = useChat()
|
|
8
|
-
const [showTerminal, setShowTerminal] = useState(true)
|
|
9
|
-
|
|
10
|
-
// Show terminal until connected, then switch to chat
|
|
11
|
-
useEffect(() => {
|
|
12
|
-
const handler = (data: { port: number; connected: boolean }) => {
|
|
13
|
-
if (data.connected) setShowTerminal(false)
|
|
14
|
-
else setShowTerminal(true)
|
|
15
|
-
}
|
|
16
|
-
window.rune.on('rune:channelStatus', handler)
|
|
17
|
-
return () => window.rune.off('rune:channelStatus', handler)
|
|
18
|
-
}, [])
|
|
19
|
-
|
|
20
|
-
const toggleTerminal = useCallback(() => setShowTerminal(prev => !prev), [])
|
|
21
|
-
|
|
22
|
-
if (!chat.runeInfo) {
|
|
23
|
-
return (
|
|
24
|
-
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
|
25
|
-
<div className="flex flex-col items-center gap-4">
|
|
26
|
-
<Bot className="h-12 w-12 text-accent" />
|
|
27
|
-
<p className="text-sm text-muted">Open a .rune file to get started</p>
|
|
28
|
-
</div>
|
|
29
|
-
</div>
|
|
30
|
-
)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return (
|
|
34
|
-
<div className="flex h-screen bg-background text-foreground text-[13px] overflow-hidden">
|
|
35
|
-
<div className="flex-1 overflow-hidden">
|
|
36
|
-
<ChatPanel
|
|
37
|
-
chat={chat}
|
|
38
|
-
showTerminal={showTerminal}
|
|
39
|
-
onToggleTerminal={toggleTerminal}
|
|
40
|
-
/>
|
|
41
|
-
</div>
|
|
42
|
-
</div>
|
|
43
|
-
)
|
|
44
|
-
}
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
|
-
import { Brain, Terminal, FileText, Pencil, Search, FolderOpen, ChevronRight, ChevronDown, Code, FileCode } from 'lucide-react'
|
|
3
|
-
import type { ContentBlock } from './types'
|
|
4
|
-
|
|
5
|
-
const TOOL_ICONS: Record<string, typeof Terminal> = {
|
|
6
|
-
Bash: Terminal,
|
|
7
|
-
Read: FileText,
|
|
8
|
-
Write: FileCode,
|
|
9
|
-
Edit: Pencil,
|
|
10
|
-
Grep: Search,
|
|
11
|
-
Glob: FolderOpen,
|
|
12
|
-
Task: Code,
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function getToolIcon(tool?: string) {
|
|
16
|
-
if (!tool) return Terminal
|
|
17
|
-
return TOOL_ICONS[tool] || Terminal
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function formatArgs(args?: Record<string, unknown>): string {
|
|
21
|
-
if (!args) return ''
|
|
22
|
-
const entries = Object.entries(args)
|
|
23
|
-
if (entries.length === 0) return ''
|
|
24
|
-
return entries
|
|
25
|
-
.map(([k, v]) => {
|
|
26
|
-
const val = typeof v === 'string' ? v : JSON.stringify(v)
|
|
27
|
-
const truncated = val.length > 120 ? val.slice(0, 120) + '...' : val
|
|
28
|
-
return `${k}: ${truncated}`
|
|
29
|
-
})
|
|
30
|
-
.join('\n')
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function ThinkingBlock({ block }: { block: ContentBlock }) {
|
|
34
|
-
const [expanded, setExpanded] = useState(false)
|
|
35
|
-
const hasContent = block.content && block.content.trim().length > 0
|
|
36
|
-
const preview = block.content
|
|
37
|
-
? block.content.length > 80
|
|
38
|
-
? block.content.slice(0, 80) + '...'
|
|
39
|
-
: block.content
|
|
40
|
-
: ''
|
|
41
|
-
|
|
42
|
-
return (
|
|
43
|
-
<div className="activity-block activity-thinking">
|
|
44
|
-
<button
|
|
45
|
-
className="activity-header"
|
|
46
|
-
onClick={() => hasContent && setExpanded(!expanded)}
|
|
47
|
-
>
|
|
48
|
-
<Brain className="activity-icon h-3.5 w-3.5 text-purple-400 shrink-0" />
|
|
49
|
-
<span className="activity-label text-purple-400">Thinking</span>
|
|
50
|
-
{!expanded && preview && (
|
|
51
|
-
<span className="activity-preview">{preview}</span>
|
|
52
|
-
)}
|
|
53
|
-
{hasContent && (
|
|
54
|
-
expanded
|
|
55
|
-
? <ChevronDown className="h-3 w-3 text-muted shrink-0 ml-auto" />
|
|
56
|
-
: <ChevronRight className="h-3 w-3 text-muted shrink-0 ml-auto" />
|
|
57
|
-
)}
|
|
58
|
-
</button>
|
|
59
|
-
{expanded && hasContent && (
|
|
60
|
-
<div className="activity-body activity-thinking-body">
|
|
61
|
-
{block.content}
|
|
62
|
-
</div>
|
|
63
|
-
)}
|
|
64
|
-
</div>
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function ToolUseBlock({ block }: { block: ContentBlock }) {
|
|
69
|
-
const [expanded, setExpanded] = useState(false)
|
|
70
|
-
const Icon = getToolIcon(block.tool)
|
|
71
|
-
const argsText = formatArgs(block.args)
|
|
72
|
-
|
|
73
|
-
return (
|
|
74
|
-
<div className="activity-block activity-tool-use">
|
|
75
|
-
<button
|
|
76
|
-
className="activity-header"
|
|
77
|
-
onClick={() => argsText && setExpanded(!expanded)}
|
|
78
|
-
>
|
|
79
|
-
<Icon className="activity-icon h-3.5 w-3.5 text-accent shrink-0" />
|
|
80
|
-
<span className="activity-label text-accent">{block.tool || 'Tool'}</span>
|
|
81
|
-
{!expanded && block.args && (
|
|
82
|
-
<span className="activity-preview">
|
|
83
|
-
{Object.entries(block.args).map(([k, v]) => {
|
|
84
|
-
const val = typeof v === 'string' ? v : JSON.stringify(v)
|
|
85
|
-
return val.length > 60 ? val.slice(0, 60) + '...' : val
|
|
86
|
-
}).join(' ')}
|
|
87
|
-
</span>
|
|
88
|
-
)}
|
|
89
|
-
{argsText && (
|
|
90
|
-
expanded
|
|
91
|
-
? <ChevronDown className="h-3 w-3 text-muted shrink-0 ml-auto" />
|
|
92
|
-
: <ChevronRight className="h-3 w-3 text-muted shrink-0 ml-auto" />
|
|
93
|
-
)}
|
|
94
|
-
</button>
|
|
95
|
-
{expanded && argsText && (
|
|
96
|
-
<div className="activity-body">
|
|
97
|
-
<pre className="activity-args">{argsText}</pre>
|
|
98
|
-
</div>
|
|
99
|
-
)}
|
|
100
|
-
</div>
|
|
101
|
-
)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function ToolResultBlock({ block }: { block: ContentBlock }) {
|
|
105
|
-
const [expanded, setExpanded] = useState(false)
|
|
106
|
-
const Icon = getToolIcon(block.tool)
|
|
107
|
-
const hasContent = block.content && block.content.trim().length > 0
|
|
108
|
-
const preview = block.content
|
|
109
|
-
? block.content.length > 100
|
|
110
|
-
? block.content.slice(0, 100) + '...'
|
|
111
|
-
: block.content
|
|
112
|
-
: ''
|
|
113
|
-
|
|
114
|
-
return (
|
|
115
|
-
<div className="activity-block activity-tool-result">
|
|
116
|
-
<button
|
|
117
|
-
className="activity-header"
|
|
118
|
-
onClick={() => hasContent && setExpanded(!expanded)}
|
|
119
|
-
>
|
|
120
|
-
<Icon className="activity-icon h-3.5 w-3.5 text-emerald-400 shrink-0" />
|
|
121
|
-
<span className="activity-label text-emerald-400">{block.tool || 'Result'}</span>
|
|
122
|
-
<span className="activity-result-badge">done</span>
|
|
123
|
-
{!expanded && preview && (
|
|
124
|
-
<span className="activity-preview">{preview}</span>
|
|
125
|
-
)}
|
|
126
|
-
{hasContent && (
|
|
127
|
-
expanded
|
|
128
|
-
? <ChevronDown className="h-3 w-3 text-muted shrink-0 ml-auto" />
|
|
129
|
-
: <ChevronRight className="h-3 w-3 text-muted shrink-0 ml-auto" />
|
|
130
|
-
)}
|
|
131
|
-
</button>
|
|
132
|
-
{expanded && hasContent && (
|
|
133
|
-
<div className="activity-body">
|
|
134
|
-
<pre className="activity-result-content">{block.content}</pre>
|
|
135
|
-
</div>
|
|
136
|
-
)}
|
|
137
|
-
</div>
|
|
138
|
-
)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export function ActivityBlock({ block }: { block: ContentBlock }) {
|
|
142
|
-
switch (block.type) {
|
|
143
|
-
case 'thinking':
|
|
144
|
-
return <ThinkingBlock block={block} />
|
|
145
|
-
case 'tool_use':
|
|
146
|
-
return <ToolUseBlock block={block} />
|
|
147
|
-
case 'tool_result':
|
|
148
|
-
return <ToolResultBlock block={block} />
|
|
149
|
-
default:
|
|
150
|
-
return null
|
|
151
|
-
}
|
|
152
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
|
-
import { Trash2, SquareTerminal } from 'lucide-react'
|
|
3
|
-
import { cn } from '@/lib/utils'
|
|
4
|
-
import { useIPCOn } from '@/hooks/use-ipc'
|
|
5
|
-
|
|
6
|
-
interface ChatHeaderProps {
|
|
7
|
-
name: string
|
|
8
|
-
role?: string
|
|
9
|
-
port: number
|
|
10
|
-
showTerminal?: boolean
|
|
11
|
-
onClearHistory: () => void
|
|
12
|
-
onToggleTerminal?: () => void
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function ChatHeader({ name, role, port, showTerminal, onClearHistory, onToggleTerminal }: ChatHeaderProps) {
|
|
16
|
-
const [connected, setConnected] = useState(false)
|
|
17
|
-
useIPCOn('rune:channelStatus', (data: { port: number; connected: boolean }) => {
|
|
18
|
-
if (data.port === port) setConnected(data.connected)
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
const dotColor = connected ? 'bg-accent' : 'bg-accent-red'
|
|
22
|
-
|
|
23
|
-
return (
|
|
24
|
-
<div
|
|
25
|
-
className="flex items-center justify-between px-4 h-[40px] border-b border-border shrink-0"
|
|
26
|
-
style={{ WebkitAppRegion: 'drag' as any }}
|
|
27
|
-
>
|
|
28
|
-
<div className="flex items-center gap-2" style={{ marginLeft: process.platform === 'darwin' ? 68 : 0 }}>
|
|
29
|
-
<span className="text-[13px] font-medium text-foreground">{name}</span>
|
|
30
|
-
<div className={cn('w-2 h-2 rounded-full', dotColor, !connected && 'animate-pulse')} />
|
|
31
|
-
</div>
|
|
32
|
-
|
|
33
|
-
<div className="flex items-center gap-1.5" style={{ WebkitAppRegion: 'no-drag' as any }}>
|
|
34
|
-
{onToggleTerminal && (
|
|
35
|
-
<button
|
|
36
|
-
className={cn(
|
|
37
|
-
'inline-flex items-center justify-center rounded-md h-7 w-7 transition-colors',
|
|
38
|
-
showTerminal
|
|
39
|
-
? 'text-accent bg-accent/10'
|
|
40
|
-
: 'text-muted hover:text-foreground hover:bg-border'
|
|
41
|
-
)}
|
|
42
|
-
title="Toggle terminal"
|
|
43
|
-
onClick={() => onToggleTerminal?.()}
|
|
44
|
-
>
|
|
45
|
-
<SquareTerminal className="h-3.5 w-3.5" />
|
|
46
|
-
</button>
|
|
47
|
-
)}
|
|
48
|
-
<button
|
|
49
|
-
className="inline-flex items-center justify-center rounded-md h-7 w-7 text-muted hover:text-foreground hover:bg-border transition-colors"
|
|
50
|
-
title="Clear history"
|
|
51
|
-
onClick={onClearHistory}
|
|
52
|
-
>
|
|
53
|
-
<Trash2 className="h-3.5 w-3.5" />
|
|
54
|
-
</button>
|
|
55
|
-
</div>
|
|
56
|
-
</div>
|
|
57
|
-
)
|
|
58
|
-
}
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
import { useRef, useCallback, useState, type DragEvent } from 'react'
|
|
2
|
-
import { ArrowUp, Square, Paperclip, X } from 'lucide-react'
|
|
3
|
-
import { cn } from '@/lib/utils'
|
|
4
|
-
|
|
5
|
-
interface ChatInputProps {
|
|
6
|
-
isStreaming: boolean
|
|
7
|
-
disabled?: boolean
|
|
8
|
-
onSend: (content: string, files?: string[]) => void
|
|
9
|
-
onCancel: () => void
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function ChatInput({ isStreaming, disabled, onSend, onCancel }: ChatInputProps) {
|
|
13
|
-
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
14
|
-
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
15
|
-
const [attachedFiles, setAttachedFiles] = useState<string[]>([])
|
|
16
|
-
const [isDragOver, setIsDragOver] = useState(false)
|
|
17
|
-
|
|
18
|
-
const handleSend = useCallback(() => {
|
|
19
|
-
if (disabled) return
|
|
20
|
-
const content = textareaRef.current?.value.trim()
|
|
21
|
-
if (!content && attachedFiles.length === 0) return
|
|
22
|
-
onSend(content || '', attachedFiles.length > 0 ? attachedFiles : undefined)
|
|
23
|
-
if (textareaRef.current) {
|
|
24
|
-
textareaRef.current.value = ''
|
|
25
|
-
textareaRef.current.style.height = 'auto'
|
|
26
|
-
}
|
|
27
|
-
setAttachedFiles([])
|
|
28
|
-
}, [disabled, onSend, attachedFiles])
|
|
29
|
-
|
|
30
|
-
const handleSendOrCancel = useCallback(() => {
|
|
31
|
-
const hasContent = textareaRef.current?.value.trim() || attachedFiles.length > 0
|
|
32
|
-
// If streaming but user typed something, send it (will auto-cancel current stream)
|
|
33
|
-
// If streaming with empty input, just cancel
|
|
34
|
-
if (isStreaming && !hasContent) onCancel()
|
|
35
|
-
else handleSend()
|
|
36
|
-
}, [isStreaming, onCancel, handleSend, attachedFiles.length])
|
|
37
|
-
|
|
38
|
-
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
39
|
-
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
40
|
-
e.preventDefault()
|
|
41
|
-
handleSend()
|
|
42
|
-
}
|
|
43
|
-
}, [handleSend])
|
|
44
|
-
|
|
45
|
-
const handleInput = useCallback(() => {
|
|
46
|
-
const el = textareaRef.current
|
|
47
|
-
if (el) {
|
|
48
|
-
el.style.height = 'auto'
|
|
49
|
-
el.style.height = `${Math.min(el.scrollHeight, 120)}px`
|
|
50
|
-
}
|
|
51
|
-
}, [])
|
|
52
|
-
|
|
53
|
-
const handleFileClick = useCallback(() => {
|
|
54
|
-
fileInputRef.current?.click()
|
|
55
|
-
}, [])
|
|
56
|
-
|
|
57
|
-
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
58
|
-
const files = e.target.files
|
|
59
|
-
if (!files) return
|
|
60
|
-
const paths: string[] = []
|
|
61
|
-
for (let i = 0; i < files.length; i++) {
|
|
62
|
-
const f = files[i] as any
|
|
63
|
-
if (f.path) paths.push(f.path)
|
|
64
|
-
}
|
|
65
|
-
if (paths.length > 0) {
|
|
66
|
-
setAttachedFiles(prev => [...prev, ...paths])
|
|
67
|
-
}
|
|
68
|
-
// Reset input so same file can be re-selected
|
|
69
|
-
e.target.value = ''
|
|
70
|
-
}, [])
|
|
71
|
-
|
|
72
|
-
const removeFile = useCallback((index: number) => {
|
|
73
|
-
setAttachedFiles(prev => prev.filter((_, i) => i !== index))
|
|
74
|
-
}, [])
|
|
75
|
-
|
|
76
|
-
const handleDragOver = useCallback((e: DragEvent) => {
|
|
77
|
-
e.preventDefault()
|
|
78
|
-
e.stopPropagation()
|
|
79
|
-
setIsDragOver(true)
|
|
80
|
-
}, [])
|
|
81
|
-
|
|
82
|
-
const handleDragLeave = useCallback((e: DragEvent) => {
|
|
83
|
-
e.preventDefault()
|
|
84
|
-
e.stopPropagation()
|
|
85
|
-
// Only set false if leaving the container (not entering a child)
|
|
86
|
-
if (e.currentTarget && !e.currentTarget.contains(e.relatedTarget as Node)) {
|
|
87
|
-
setIsDragOver(false)
|
|
88
|
-
}
|
|
89
|
-
}, [])
|
|
90
|
-
|
|
91
|
-
const handleDrop = useCallback((e: DragEvent) => {
|
|
92
|
-
e.preventDefault()
|
|
93
|
-
e.stopPropagation()
|
|
94
|
-
setIsDragOver(false)
|
|
95
|
-
const files = e.dataTransfer?.files
|
|
96
|
-
if (!files || files.length === 0) return
|
|
97
|
-
const paths: string[] = []
|
|
98
|
-
for (let i = 0; i < files.length; i++) {
|
|
99
|
-
const f = files[i] as any
|
|
100
|
-
if (f.path) paths.push(f.path)
|
|
101
|
-
}
|
|
102
|
-
if (paths.length > 0) {
|
|
103
|
-
setAttachedFiles(prev => [...prev, ...paths])
|
|
104
|
-
}
|
|
105
|
-
}, [])
|
|
106
|
-
|
|
107
|
-
const getFileName = (filePath: string) => {
|
|
108
|
-
return filePath.split('/').pop() || filePath
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return (
|
|
112
|
-
<div
|
|
113
|
-
className={cn(
|
|
114
|
-
'flex flex-col gap-2 px-4 py-3.5 border-t shrink-0 relative transition-colors',
|
|
115
|
-
isDragOver ? 'border-accent bg-accent/5' : 'border-border'
|
|
116
|
-
)}
|
|
117
|
-
onDragOver={handleDragOver}
|
|
118
|
-
onDragLeave={handleDragLeave}
|
|
119
|
-
onDrop={handleDrop}
|
|
120
|
-
>
|
|
121
|
-
{isDragOver && (
|
|
122
|
-
<div className="absolute inset-0 flex items-center justify-center bg-accent/10 border-2 border-dashed border-accent rounded-lg z-10 pointer-events-none">
|
|
123
|
-
<span className="text-[12px] font-medium text-accent">Drop files here</span>
|
|
124
|
-
</div>
|
|
125
|
-
)}
|
|
126
|
-
{attachedFiles.length > 0 && (
|
|
127
|
-
<div className="flex flex-wrap gap-1.5">
|
|
128
|
-
{attachedFiles.map((file, i) => (
|
|
129
|
-
<div
|
|
130
|
-
key={i}
|
|
131
|
-
className="flex items-center gap-1.5 bg-muted/30 border border-border rounded-lg px-2.5 py-1.5 text-[11px] text-muted-foreground max-w-[200px]"
|
|
132
|
-
>
|
|
133
|
-
<Paperclip className="h-3 w-3 shrink-0 opacity-50" />
|
|
134
|
-
<span className="truncate">{getFileName(file)}</span>
|
|
135
|
-
<button
|
|
136
|
-
onClick={() => removeFile(i)}
|
|
137
|
-
className="shrink-0 hover:text-foreground transition-colors"
|
|
138
|
-
>
|
|
139
|
-
<X className="h-3 w-3" />
|
|
140
|
-
</button>
|
|
141
|
-
</div>
|
|
142
|
-
))}
|
|
143
|
-
</div>
|
|
144
|
-
)}
|
|
145
|
-
<div className="flex gap-2.5 items-end">
|
|
146
|
-
<button
|
|
147
|
-
className="inline-flex items-center justify-center rounded-xl h-[38px] w-[38px] shrink-0 transition-colors text-muted-foreground hover:text-foreground hover:bg-muted/30"
|
|
148
|
-
onClick={handleFileClick}
|
|
149
|
-
title="Attach file"
|
|
150
|
-
>
|
|
151
|
-
<Paperclip className="h-4 w-4" />
|
|
152
|
-
</button>
|
|
153
|
-
<input
|
|
154
|
-
ref={fileInputRef}
|
|
155
|
-
type="file"
|
|
156
|
-
multiple
|
|
157
|
-
className="hidden"
|
|
158
|
-
onChange={handleFileChange}
|
|
159
|
-
/>
|
|
160
|
-
<textarea
|
|
161
|
-
ref={textareaRef}
|
|
162
|
-
className={cn(
|
|
163
|
-
'flex-1 rounded-xl border border-input bg-transparent px-3.5 py-2.5 text-[13px] text-foreground resize-none outline-none min-h-[42px] max-h-[120px] leading-[1.5] transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-0',
|
|
164
|
-
disabled && 'opacity-50 cursor-not-allowed'
|
|
165
|
-
)}
|
|
166
|
-
placeholder={disabled ? 'Waiting for channel connection...' : 'Type a message...'}
|
|
167
|
-
rows={1}
|
|
168
|
-
spellCheck={false}
|
|
169
|
-
disabled={disabled}
|
|
170
|
-
onKeyDown={handleKeyDown}
|
|
171
|
-
onInput={handleInput}
|
|
172
|
-
/>
|
|
173
|
-
<button
|
|
174
|
-
className={cn(
|
|
175
|
-
'inline-flex items-center justify-center rounded-xl h-[38px] w-[38px] shrink-0 transition-colors',
|
|
176
|
-
disabled
|
|
177
|
-
? 'bg-muted text-muted-foreground cursor-not-allowed'
|
|
178
|
-
: isStreaming
|
|
179
|
-
? 'bg-accent-red text-white hover:bg-accent-red/90'
|
|
180
|
-
: 'bg-accent text-accent-foreground hover:bg-accent/90'
|
|
181
|
-
)}
|
|
182
|
-
onClick={handleSendOrCancel}
|
|
183
|
-
disabled={disabled}
|
|
184
|
-
>
|
|
185
|
-
{isStreaming ? <Square className="h-3.5 w-3.5" /> : <ArrowUp className="h-4 w-4 stroke-[2.5]" />}
|
|
186
|
-
</button>
|
|
187
|
-
</div>
|
|
188
|
-
</div>
|
|
189
|
-
)
|
|
190
|
-
}
|