@deck-ui/workspace 0.1.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/package.json +21 -0
- package/src/files-browser.tsx +291 -0
- package/src/index.ts +9 -0
- package/src/instructions-panel.tsx +97 -0
- package/src/types.ts +23 -0
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deck-ui/workspace",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"src"
|
|
9
|
+
],
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"react": "^19.0.0",
|
|
12
|
+
"react-dom": "^19.0.0",
|
|
13
|
+
"@deck-ui/core": "^0.1.0"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"lucide-react": "^0.577.0"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilesBrowser — file browser for an agent workspace.
|
|
3
|
+
* Shows files grouped by folder with icons, sizes, and actions.
|
|
4
|
+
* Extracted from Houston's FilesView, made props-driven.
|
|
5
|
+
*/
|
|
6
|
+
import { useState } from "react"
|
|
7
|
+
import {
|
|
8
|
+
cn,
|
|
9
|
+
DropdownMenu,
|
|
10
|
+
DropdownMenuContent,
|
|
11
|
+
DropdownMenuItem,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from "@deck-ui/core"
|
|
14
|
+
import {
|
|
15
|
+
ChevronRight,
|
|
16
|
+
ExternalLink,
|
|
17
|
+
FileText,
|
|
18
|
+
FolderSearch,
|
|
19
|
+
Image as ImageIcon,
|
|
20
|
+
MoreVertical,
|
|
21
|
+
Trash2,
|
|
22
|
+
} from "lucide-react"
|
|
23
|
+
import type { FileEntry } from "./types"
|
|
24
|
+
|
|
25
|
+
export interface FilesBrowserProps {
|
|
26
|
+
/** Files to display */
|
|
27
|
+
files: FileEntry[]
|
|
28
|
+
/** Show loading state */
|
|
29
|
+
loading?: boolean
|
|
30
|
+
/** Called when a file row is clicked */
|
|
31
|
+
onOpen?: (file: FileEntry) => void
|
|
32
|
+
/** Called when "Show in Finder" is selected */
|
|
33
|
+
onReveal?: (file: FileEntry) => void
|
|
34
|
+
/** Called when delete is selected */
|
|
35
|
+
onDelete?: (file: FileEntry) => void
|
|
36
|
+
/** Title for empty state */
|
|
37
|
+
emptyTitle?: string
|
|
38
|
+
/** Description for empty state */
|
|
39
|
+
emptyDescription?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function FilesBrowser({
|
|
43
|
+
files,
|
|
44
|
+
loading,
|
|
45
|
+
onOpen,
|
|
46
|
+
onReveal,
|
|
47
|
+
onDelete,
|
|
48
|
+
emptyTitle = "Your work shows up here",
|
|
49
|
+
emptyDescription = "When agents create files, they'll appear here for you to open and review.",
|
|
50
|
+
}: FilesBrowserProps) {
|
|
51
|
+
if (!loading && files.length === 0) {
|
|
52
|
+
return (
|
|
53
|
+
<div className="flex-1 flex flex-col items-center pt-[20vh] gap-4 px-8">
|
|
54
|
+
<div className="space-y-2 text-center max-w-md">
|
|
55
|
+
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
|
56
|
+
{emptyTitle}
|
|
57
|
+
</h1>
|
|
58
|
+
<p className="text-sm text-muted-foreground">{emptyDescription}</p>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const grouped = groupByFolder(files)
|
|
65
|
+
const folders = Object.keys(grouped).sort()
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex-1 flex flex-col h-full min-h-0 overflow-hidden">
|
|
69
|
+
<div className="flex items-center h-8 px-6 text-[11px] font-medium text-muted-foreground/40 border-b border-black/[0.06] shrink-0 select-none">
|
|
70
|
+
<span className="flex-1 pl-8">Name</span>
|
|
71
|
+
<span className="w-20 text-right">Size</span>
|
|
72
|
+
<span className="w-10" />
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div className="flex-1 overflow-y-auto">
|
|
76
|
+
{loading ? (
|
|
77
|
+
<div className="flex items-center justify-center py-16">
|
|
78
|
+
<p className="text-sm text-muted-foreground/50">Loading...</p>
|
|
79
|
+
</div>
|
|
80
|
+
) : (
|
|
81
|
+
<div>
|
|
82
|
+
{folders.map((folder) =>
|
|
83
|
+
folder ? (
|
|
84
|
+
<FolderSection
|
|
85
|
+
key={folder}
|
|
86
|
+
name={folder}
|
|
87
|
+
files={grouped[folder]}
|
|
88
|
+
onOpen={onOpen}
|
|
89
|
+
onReveal={onReveal}
|
|
90
|
+
onDelete={onDelete}
|
|
91
|
+
/>
|
|
92
|
+
) : (
|
|
93
|
+
grouped[folder].map((f) => (
|
|
94
|
+
<FileRow
|
|
95
|
+
key={f.path}
|
|
96
|
+
file={f}
|
|
97
|
+
onOpen={onOpen}
|
|
98
|
+
onReveal={onReveal}
|
|
99
|
+
onDelete={onDelete}
|
|
100
|
+
/>
|
|
101
|
+
))
|
|
102
|
+
),
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function groupByFolder(files: FileEntry[]): Record<string, FileEntry[]> {
|
|
112
|
+
const grouped: Record<string, FileEntry[]> = {}
|
|
113
|
+
for (const f of files) {
|
|
114
|
+
const parts = f.path.split("/")
|
|
115
|
+
const folder = parts.length > 1 ? parts.slice(0, -1).join("/") : ""
|
|
116
|
+
;(grouped[folder] ??= []).push(f)
|
|
117
|
+
}
|
|
118
|
+
return grouped
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function FolderSection({
|
|
122
|
+
name,
|
|
123
|
+
files,
|
|
124
|
+
onOpen,
|
|
125
|
+
onReveal,
|
|
126
|
+
onDelete,
|
|
127
|
+
}: {
|
|
128
|
+
name: string
|
|
129
|
+
files: FileEntry[]
|
|
130
|
+
onOpen?: (file: FileEntry) => void
|
|
131
|
+
onReveal?: (file: FileEntry) => void
|
|
132
|
+
onDelete?: (file: FileEntry) => void
|
|
133
|
+
}) {
|
|
134
|
+
const [open, setOpen] = useState(true)
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div>
|
|
138
|
+
<button
|
|
139
|
+
onClick={() => setOpen(!open)}
|
|
140
|
+
className="w-full flex items-center h-8 px-6 hover:bg-secondary transition-colors duration-150 select-none"
|
|
141
|
+
>
|
|
142
|
+
<ChevronRight
|
|
143
|
+
className={cn(
|
|
144
|
+
"size-3.5 text-muted-foreground/40 transition-transform duration-200 mr-2",
|
|
145
|
+
open && "rotate-90",
|
|
146
|
+
)}
|
|
147
|
+
/>
|
|
148
|
+
<span className="text-xs font-medium text-muted-foreground/60 flex-1 text-left">
|
|
149
|
+
{name}
|
|
150
|
+
</span>
|
|
151
|
+
<span className="text-[10px] text-muted-foreground/30">
|
|
152
|
+
{files.length}
|
|
153
|
+
</span>
|
|
154
|
+
</button>
|
|
155
|
+
{open && (
|
|
156
|
+
<div>
|
|
157
|
+
{files.map((f) => (
|
|
158
|
+
<FileRow
|
|
159
|
+
key={f.path}
|
|
160
|
+
file={f}
|
|
161
|
+
indent
|
|
162
|
+
onOpen={onOpen}
|
|
163
|
+
onReveal={onReveal}
|
|
164
|
+
onDelete={onDelete}
|
|
165
|
+
/>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function FileRow({
|
|
174
|
+
file,
|
|
175
|
+
indent,
|
|
176
|
+
onOpen,
|
|
177
|
+
onReveal,
|
|
178
|
+
onDelete,
|
|
179
|
+
}: {
|
|
180
|
+
file: FileEntry
|
|
181
|
+
indent?: boolean
|
|
182
|
+
onOpen?: (file: FileEntry) => void
|
|
183
|
+
onReveal?: (file: FileEntry) => void
|
|
184
|
+
onDelete?: (file: FileEntry) => void
|
|
185
|
+
}) {
|
|
186
|
+
const ext = file.extension
|
|
187
|
+
const hasActions = onOpen || onReveal || onDelete
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<button
|
|
191
|
+
onClick={() => onOpen?.(file)}
|
|
192
|
+
className={cn(
|
|
193
|
+
"w-full flex items-center h-9 px-6 hover:bg-secondary",
|
|
194
|
+
"active:bg-accent transition-colors duration-100 text-left group",
|
|
195
|
+
indent && "pl-11",
|
|
196
|
+
)}
|
|
197
|
+
>
|
|
198
|
+
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
|
199
|
+
<FileIcon extension={ext} />
|
|
200
|
+
<span className="text-[13px] text-foreground truncate">
|
|
201
|
+
{file.name}
|
|
202
|
+
</span>
|
|
203
|
+
</div>
|
|
204
|
+
<span className="w-20 text-right text-[11px] text-muted-foreground/30">
|
|
205
|
+
{formatSize(file.size)}
|
|
206
|
+
</span>
|
|
207
|
+
{hasActions && (
|
|
208
|
+
<div className="w-10 flex justify-end">
|
|
209
|
+
<DropdownMenu>
|
|
210
|
+
<DropdownMenuTrigger asChild>
|
|
211
|
+
<span
|
|
212
|
+
role="button"
|
|
213
|
+
onClick={(e) => e.stopPropagation()}
|
|
214
|
+
className="flex items-center justify-center w-6 h-6 rounded text-muted-foreground/20 hover:text-foreground hover:bg-black/[0.05] opacity-0 group-hover:opacity-100 focus:opacity-100 transition-all duration-150"
|
|
215
|
+
>
|
|
216
|
+
<MoreVertical className="size-3.5" />
|
|
217
|
+
</span>
|
|
218
|
+
</DropdownMenuTrigger>
|
|
219
|
+
<DropdownMenuContent align="end">
|
|
220
|
+
{onOpen && (
|
|
221
|
+
<DropdownMenuItem
|
|
222
|
+
onClick={(e) => {
|
|
223
|
+
e.stopPropagation()
|
|
224
|
+
onOpen(file)
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<ExternalLink className="size-4 mr-2" />
|
|
228
|
+
Open
|
|
229
|
+
</DropdownMenuItem>
|
|
230
|
+
)}
|
|
231
|
+
{onReveal && (
|
|
232
|
+
<DropdownMenuItem
|
|
233
|
+
onClick={(e) => {
|
|
234
|
+
e.stopPropagation()
|
|
235
|
+
onReveal(file)
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
<FolderSearch className="size-4 mr-2" />
|
|
239
|
+
Show in Finder
|
|
240
|
+
</DropdownMenuItem>
|
|
241
|
+
)}
|
|
242
|
+
{onDelete && (
|
|
243
|
+
<DropdownMenuItem
|
|
244
|
+
onClick={(e) => {
|
|
245
|
+
e.stopPropagation()
|
|
246
|
+
onDelete(file)
|
|
247
|
+
}}
|
|
248
|
+
className="text-destructive focus:text-destructive"
|
|
249
|
+
>
|
|
250
|
+
<Trash2 className="size-4 mr-2" />
|
|
251
|
+
Delete
|
|
252
|
+
</DropdownMenuItem>
|
|
253
|
+
)}
|
|
254
|
+
</DropdownMenuContent>
|
|
255
|
+
</DropdownMenu>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
</button>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function FileIcon({ extension }: { extension: string }) {
|
|
263
|
+
if (["png", "jpg", "jpeg", "svg", "gif", "webp"].includes(extension)) {
|
|
264
|
+
return <ImageIcon className="size-4 shrink-0 text-muted-foreground/50" />
|
|
265
|
+
}
|
|
266
|
+
if (extension === "pdf") {
|
|
267
|
+
return (
|
|
268
|
+
<svg className="size-4 shrink-0" viewBox="0 0 16 16" fill="none">
|
|
269
|
+
<rect width="16" height="16" rx="3" fill="#E5252A" />
|
|
270
|
+
<text
|
|
271
|
+
x="8"
|
|
272
|
+
y="11"
|
|
273
|
+
textAnchor="middle"
|
|
274
|
+
fill="white"
|
|
275
|
+
fontSize="7"
|
|
276
|
+
fontWeight="700"
|
|
277
|
+
fontFamily="system-ui, sans-serif"
|
|
278
|
+
>
|
|
279
|
+
PDF
|
|
280
|
+
</text>
|
|
281
|
+
</svg>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
return <FileText className="size-4 shrink-0 text-muted-foreground/50" />
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function formatSize(bytes: number): string {
|
|
288
|
+
if (bytes < 1024) return `${bytes} B`
|
|
289
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
290
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
291
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
export type { FileEntry, InstructionFile } from "./types"
|
|
3
|
+
|
|
4
|
+
// Components
|
|
5
|
+
export { FilesBrowser } from "./files-browser"
|
|
6
|
+
export type { FilesBrowserProps } from "./files-browser"
|
|
7
|
+
|
|
8
|
+
export { InstructionsPanel } from "./instructions-panel"
|
|
9
|
+
export type { InstructionsPanelProps } from "./instructions-panel"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InstructionsPanel — editable instruction files for an agent workspace.
|
|
3
|
+
* Visual style matches Houston's ContextTab exactly: labeled textareas
|
|
4
|
+
* with auto-save on blur, bg-[#f9f9f9], subtle borders.
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useState } from "react"
|
|
7
|
+
import type { InstructionFile } from "./types"
|
|
8
|
+
|
|
9
|
+
export interface InstructionsPanelProps {
|
|
10
|
+
/** Instruction files to display */
|
|
11
|
+
files: InstructionFile[]
|
|
12
|
+
/** Called when a file is edited and the textarea loses focus */
|
|
13
|
+
onSave: (name: string, content: string) => Promise<void>
|
|
14
|
+
/** Title for empty state */
|
|
15
|
+
emptyTitle?: string
|
|
16
|
+
/** Description for empty state */
|
|
17
|
+
emptyDescription?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function InstructionsPanel({
|
|
21
|
+
files,
|
|
22
|
+
onSave,
|
|
23
|
+
emptyTitle = "No instructions yet",
|
|
24
|
+
emptyDescription = "Add a CLAUDE.md to this workspace to configure how the agent behaves.",
|
|
25
|
+
}: InstructionsPanelProps) {
|
|
26
|
+
if (files.length === 0) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex-1 flex flex-col items-center pt-[20vh] gap-4 px-8">
|
|
29
|
+
<div className="space-y-2 text-center max-w-md">
|
|
30
|
+
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
|
31
|
+
{emptyTitle}
|
|
32
|
+
</h1>
|
|
33
|
+
<p className="text-sm text-muted-foreground">{emptyDescription}</p>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex-1 overflow-y-auto">
|
|
41
|
+
<div className="px-6 py-6">
|
|
42
|
+
<div className="space-y-4">
|
|
43
|
+
{files.map((file) => (
|
|
44
|
+
<InstructionField
|
|
45
|
+
key={file.name}
|
|
46
|
+
file={file}
|
|
47
|
+
onSave={(content) => onSave(file.name, content)}
|
|
48
|
+
/>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function InstructionField({
|
|
57
|
+
file,
|
|
58
|
+
onSave,
|
|
59
|
+
}: {
|
|
60
|
+
file: InstructionFile
|
|
61
|
+
onSave: (content: string) => Promise<void>
|
|
62
|
+
}) {
|
|
63
|
+
const [value, setValue] = useState(file.content)
|
|
64
|
+
const [saving, setSaving] = useState(false)
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
setValue(file.content)
|
|
68
|
+
}, [file.content])
|
|
69
|
+
|
|
70
|
+
const handleBlur = async () => {
|
|
71
|
+
if (value !== file.content) {
|
|
72
|
+
setSaving(true)
|
|
73
|
+
await onSave(value)
|
|
74
|
+
setSaving(false)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="space-y-1.5">
|
|
80
|
+
<label className="text-xs font-medium text-muted-foreground/50 px-1 flex items-center gap-2">
|
|
81
|
+
{file.label}
|
|
82
|
+
{saving && (
|
|
83
|
+
<span className="text-[10px] text-muted-foreground/30">
|
|
84
|
+
Saving...
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
</label>
|
|
88
|
+
<textarea
|
|
89
|
+
value={value}
|
|
90
|
+
onChange={(e) => setValue(e.target.value)}
|
|
91
|
+
onBlur={handleBlur}
|
|
92
|
+
rows={Math.max(4, value.split("\n").length + 1)}
|
|
93
|
+
className="w-full text-sm text-foreground leading-relaxed bg-[#f9f9f9] outline-none rounded-xl px-4 py-3 border border-black/[0.04] hover:border-black/[0.1] focus:border-black/[0.15] focus:bg-white transition-all duration-200 resize-none placeholder:text-muted-foreground/30"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// --- Files browser ---
|
|
2
|
+
|
|
3
|
+
export interface FileEntry {
|
|
4
|
+
/** Relative path from workspace root (e.g., "docs/readme.md") */
|
|
5
|
+
path: string
|
|
6
|
+
/** File name with extension */
|
|
7
|
+
name: string
|
|
8
|
+
/** File extension without dot (e.g., "md", "pdf") */
|
|
9
|
+
extension: string
|
|
10
|
+
/** File size in bytes */
|
|
11
|
+
size: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// --- Instructions panel ---
|
|
15
|
+
|
|
16
|
+
export interface InstructionFile {
|
|
17
|
+
/** File name (e.g., "CLAUDE.md") */
|
|
18
|
+
name: string
|
|
19
|
+
/** Human-readable label shown above the field (e.g., "CLAUDE.md") */
|
|
20
|
+
label: string
|
|
21
|
+
/** Current file content */
|
|
22
|
+
content: string
|
|
23
|
+
}
|