@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 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
+ }