Unified · Split
| @@ -150,6 +150,14 @@ · 149 unmodified lines | ||
| 150 | 150 | } |
| 151 | 151 | } |
| 152 | 152 | |
| 153 | +@media (max-width: 768px) { | |
| 154 | + input, | |
| 155 | + textarea, | |
| 156 | + select { | |
| 157 | + font-size: 16px !important; | |
| 158 | + } | |
| 159 | +} | |
| 160 | + | |
| 153 | 161 | /* ── Shiki / Streamdown code block theme overrides ─────────────────────────── */ |
| 154 | 162 | /* Light mode: use shiki-light tokens */ |
| 155 | 163 | [style*="--shiki-light"] { |
| @@ -493,4 +501,3 @@ · 337 unmodified lines | ||
| 493 | 501 | margin: 0; |
| 494 | 502 | padding: 0 0 0 1rem; |
| 495 | 503 | } |
| 496 | − | |
| @@ -117,7 +117,7 @@ function RepoStarLink() { · 116 unmodified lines | ||
| 117 | 117 | href={EXECUTOR_REPO_URL} |
| 118 | 118 | target="_blank" |
| 119 | 119 | rel="noreferrer" |
| 120 | − className="group flex items-center justify-between px-1 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground" | |
| 120 | + className="group flex items-center justify-between px-1 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground" | |
| 121 | 121 | > |
| 122 | 122 | <span className="flex items-center gap-1.5"> |
| 123 | 123 | <Github className="h-3.5 w-3.5" /> |
| @@ -134,7 +134,7 @@ function FeedbackLink() { · 10 unmodified lines | ||
| 134 | 134 | href={TWITTER_FEEDBACK_URL} |
| 135 | 135 | target="_blank" |
| 136 | 136 | rel="noreferrer" |
| 137 | − className="group flex items-center justify-between px-1 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground" | |
| 137 | + className="group flex items-center justify-between px-1 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground" | |
| 138 | 138 | > |
| 139 | 139 | <span className="flex items-center gap-1.5"> |
| 140 | 140 | <MessageCircle className="h-3.5 w-3.5" /> |
| @@ -67,25 +67,25 @@ function ApprovalCard({ · 66 unmodified lines | ||
| 67 | 67 | ); |
| 68 | 68 | |
| 69 | 69 | return ( |
| 70 | − <Card className="rounded-none bg-card/40 border-border/50 border-l-2 border-l-terminal-amber glow-amber"> | |
| 70 | + <Card className="rounded-none border-border/50 border-l-2 border-l-primary/40 bg-card/40"> | |
| 71 | 71 | <CardContent className="p-4 space-y-3"> |
| 72 | 72 | {/* Header */} |
| 73 | 73 | <div className="flex items-start justify-between gap-3"> |
| 74 | 74 | <div className="min-w-0"> |
| 75 | 75 | <div className="flex items-center gap-2"> |
| 76 | − <ShieldCheck className="h-4 w-4 text-terminal-amber shrink-0" /> | |
| 77 | − <span className="text-sm font-mono font-medium text-foreground truncate"> | |
| 76 | + <ShieldCheck className="h-4 w-4 shrink-0 text-primary" /> | |
| 77 | + <span className="truncate text-sm font-medium text-foreground"> | |
| 78 | 78 | {approval.toolPath} |
| 79 | 79 | </span> |
| 80 | 80 | </div> |
| 81 | 81 | <div className="flex items-center gap-2 mt-1 pl-6"> |
| 82 | − <span className="text-[11px] text-muted-foreground"> | |
| 82 | + <span className="text-xs text-muted-foreground"> | |
| 83 | 83 | Task: {approval.taskId} |
| 84 | 84 | </span> |
| 85 | − <span className="text-[11px] text-muted-foreground"> | |
| 85 | + <span className="text-xs text-muted-foreground"> | |
| 86 | 86 | · |
| 87 | 87 | </span> |
| 88 | − <span className="text-[11px] text-muted-foreground"> | |
| 88 | + <span className="text-xs text-muted-foreground"> | |
| 89 | 89 | {formatTimeAgo(approval.createdAt)} |
| 90 | 90 | </span> |
| 91 | 91 | </div> |
| @@ -106,7 +106,7 @@ function ApprovalCard({ · 14 unmodified lines | ||
| 106 | 106 | {/* Input */} |
| 107 | 107 | {inputDisplay && ( |
| 108 | 108 | <div> |
| 109 | − <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-1.5"> | |
| 109 | + <span className="mb-1.5 block text-xs font-medium text-muted-foreground"> | |
| 110 | 110 | Input |
| 111 | 111 | </span> |
| 112 | 112 | <FormattedCodeBlock |
| @@ -124,7 +124,7 @@ function ApprovalCard({ · 11 unmodified lines | ||
| 124 | 124 | <Button |
| 125 | 125 | onClick={() => handleResolve("approved")} |
| 126 | 126 | disabled={resolving !== null} |
| 127 | − className="flex-1 bg-terminal-green/15 text-terminal-green border border-terminal-green/30 hover:bg-terminal-green/25 h-9" | |
| 127 | + className="h-9 flex-1 border border-primary/30 bg-primary/10 text-primary hover:bg-primary/20" | |
| 128 | 128 | variant="outline" |
| 129 | 129 | size="sm" |
| 130 | 130 | > |
| @@ -134,7 +134,7 @@ function ApprovalCard({ · 3 unmodified lines | ||
| 134 | 134 | <Button |
| 135 | 135 | onClick={() => handleResolve("denied")} |
| 136 | 136 | disabled={resolving !== null} |
| 137 | − className="flex-1 bg-terminal-red/15 text-terminal-red border border-terminal-red/30 hover:bg-terminal-red/25 h-9" | |
| 137 | + className="h-9 flex-1 border border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/20" | |
| 138 | 138 | variant="outline" |
| 139 | 139 | size="sm" |
| 140 | 140 | > |
| @@ -158,13 +158,16 @@ export function ApprovalsView() { · 17 unmodified lines | ||
| 158 | 158 | |
| 159 | 159 | if (sessionLoading) { |
| 160 | 160 | return ( |
| 161 | − <div className="p-4 md:p-6 lg:p-8 space-y-6"> | |
| 162 | − <Skeleton className="h-8 w-48" /> | |
| 163 | − <div className="space-y-4"> | |
| 161 | + <div className="flex h-full min-h-0 overflow-hidden bg-background"> | |
| 162 | + <div className="w-72 shrink-0 border-r border-border/40 p-3 space-y-3"> | |
| 163 | + <Skeleton className="h-8 w-full" /> | |
| 164 | 164 | {Array.from({ length: 3 }).map((_, i) => ( |
| 165 | − <Skeleton key={i} className="h-40" /> | |
| 165 | + <Skeleton key={i} className="h-16" /> | |
| 166 | 166 | ))} |
| 167 | 167 | </div> |
| 168 | + <div className="flex-1 p-4"> | |
| 169 | + <Skeleton className="h-40" /> | |
| 170 | + </div> | |
| 168 | 171 | </div> |
| 169 | 172 | ); |
| 170 | 173 | } |
| @@ -172,40 +175,90 @@ export function ApprovalsView() { · 1 unmodified line | ||
| 172 | 175 | const count = approvals?.length ?? 0; |
| 173 | 176 | |
| 174 | 177 | return ( |
| 175 | − <div className="flex h-full min-h-0 flex-col p-4 md:p-6 lg:p-8"> | |
| 176 | − {approvalsLoading ? ( | |
| 177 | − <div className="space-y-4"> | |
| 178 | − {Array.from({ length: 3 }).map((_, i) => ( | |
| 179 | − <Skeleton key={i} className="h-40" /> | |
| 180 | − ))} | |
| 178 | + <div className="flex h-full min-h-0 overflow-hidden bg-background"> | |
| 179 | + {/* ── Left sidebar: approval list ── */} | |
| 180 | + <aside className="flex h-full w-72 shrink-0 flex-col border-r border-border/40 bg-card/30 lg:w-80"> | |
| 181 | + {/* Sidebar header */} | |
| 182 | + <div className="shrink-0 border-b border-border/30 px-3 py-2.5"> | |
| 183 | + <div className="flex items-center gap-2"> | |
| 184 | + <ShieldCheck className="h-4 w-4 text-muted-foreground" /> | |
| 185 | + <h3 className="text-sm font-medium leading-none">Approvals</h3> | |
| 186 | + </div> | |
| 187 | + <p className="text-[10px] text-muted-foreground mt-1"> | |
| 188 | + {count} pending | |
| 189 | + </p> | |
| 190 | + </div> | |
| 191 | + | |
| 192 | + {/* Approval items in sidebar */} | |
| 193 | + <div className="flex-1 min-h-0 overflow-y-auto"> | |
| 194 | + {approvalsLoading ? ( | |
| 195 | + <div className="space-y-2 p-3"> | |
| 196 | + {Array.from({ length: 3 }).map((_, i) => ( | |
| 197 | + <Skeleton key={i} className="h-16" /> | |
| 198 | + ))} | |
| 199 | + </div> | |
| 200 | + ) : count === 0 ? ( | |
| 201 | + <div className="flex flex-col items-center justify-center py-16 gap-3 px-4"> | |
| 202 | + <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10"> | |
| 203 | + <CheckCircle2 className="h-5 w-5 text-primary/60" /> | |
| 204 | + </div> | |
| 205 | + <p className="text-xs text-muted-foreground text-center">No pending approvals</p> | |
| 206 | + <p className="text-[10px] text-muted-foreground/60 text-center"> | |
| 207 | + Tool calls requiring approval will appear here | |
| 208 | + </p> | |
| 209 | + </div> | |
| 210 | + ) : ( | |
| 211 | + <div className="p-2 space-y-1"> | |
| 212 | + {(approvals ?? []).map((a: PendingApprovalRecord) => ( | |
| 213 | + <div | |
| 214 | + key={a.id} | |
| 215 | + className="rounded-md border border-border/40 bg-background/70 px-2.5 py-2 transition-colors hover:bg-accent/20" | |
| 216 | + > | |
| 217 | + <div className="flex items-center gap-2"> | |
| 218 | + <ShieldCheck className="h-3.5 w-3.5 shrink-0 text-primary" /> | |
| 219 | + <span className="text-xs font-medium truncate">{a.toolPath}</span> | |
| 220 | + </div> | |
| 221 | + <div className="flex items-center gap-2 mt-1 pl-5"> | |
| 222 | + <span className="text-[10px] text-muted-foreground truncate"> | |
| 223 | + {formatTimeAgo(a.createdAt)} | |
| 224 | + </span> | |
| 225 | + <TaskStatusBadge status={a.task.status} /> | |
| 226 | + </div> | |
| 227 | + </div> | |
| 228 | + ))} | |
| 229 | + </div> | |
| 230 | + )} | |
| 181 | 231 | </div> |
| 182 | − ) : count === 0 ? ( | |
| 183 | − <Card className="rounded-none bg-card/40 border-border/50 flex-1"> | |
| 184 | − <CardContent className="flex flex-col items-center justify-center py-20 gap-3"> | |
| 185 | − <div className="h-12 w-12 rounded-full bg-terminal-green/10 flex items-center justify-center"> | |
| 186 | − <CheckCircle2 className="h-6 w-6 text-terminal-green/60" /> | |
| 232 | + </aside> | |
| 233 | + | |
| 234 | + {/* ── Right content: full approval cards ── */} | |
| 235 | + <div className="flex-1 min-w-0 max-h-screen overflow-y-auto bg-background/50"> | |
| 236 | + {approvalsLoading ? ( | |
| 237 | + <div className="p-4 space-y-4"> | |
| 238 | + {Array.from({ length: 3 }).map((_, i) => ( | |
| 239 | + <Skeleton key={i} className="h-40" /> | |
| 240 | + ))} | |
| 241 | + </div> | |
| 242 | + ) : count === 0 ? ( | |
| 243 | + <div className="flex h-full items-center justify-center"> | |
| 244 | + <div className="text-center space-y-2"> | |
| 245 | + <CheckCircle2 className="h-8 w-8 text-primary/40 mx-auto" /> | |
| 246 | + <p className="text-sm text-muted-foreground">All clear</p> | |
| 247 | + <p className="text-[11px] text-muted-foreground/60">No approvals waiting for review</p> | |
| 187 | 248 | </div> |
| 188 | − <p className="text-sm text-muted-foreground"> | |
| 189 | − No pending approvals | |
| 190 | − </p> | |
| 191 | − <p className="text-[11px] text-muted-foreground/60"> | |
| 192 | − Tool calls requiring approval will appear here | |
| 193 | − </p> | |
| 194 | − </CardContent> | |
| 195 | − </Card> | |
| 196 | − ) : ( | |
| 197 | − <div className="flex min-h-0 flex-1 flex-col max-w-2xl"> | |
| 198 | − <div className="mb-3 flex items-center gap-2 text-xs text-muted-foreground"> | |
| 199 | − <Clock className="h-3.5 w-3.5" /> | |
| 200 | − {count} pending approval{count !== 1 ? "s" : ""} | |
| 201 | 249 | </div> |
| 202 | − <div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1"> | |
| 250 | + ) : ( | |
| 251 | + <div className="p-4 sm:p-6 space-y-4 max-w-3xl"> | |
| 252 | + <div className="flex items-center gap-2 text-xs text-muted-foreground mb-2"> | |
| 253 | + <Clock className="h-3.5 w-3.5" /> | |
| 254 | + {count} pending approval{count !== 1 ? "s" : ""} | |
| 255 | + </div> | |
| 203 | 256 | {(approvals ?? []).map((a: PendingApprovalRecord) => ( |
| 204 | 257 | <ApprovalCard key={a.id} approval={a} /> |
| 205 | 258 | ))} |
| 206 | 259 | </div> |
| 207 | − </div> | |
| 208 | − )} | |
| 260 | + )} | |
| 261 | + </div> | |
| 209 | 262 | </div> |
| 210 | 263 | ); |
| 211 | 264 | } |
| @@ -7,6 +7,13 @@ import { PageHeader } from "@/components/page-header"; · 6 unmodified lines | ||
| 7 | 7 | import { Button } from "@/components/ui/button"; |
| 8 | 8 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 9 | 9 | import { Input } from "@/components/ui/input"; |
| 10 | +import { | |
| 11 | + Select, | |
| 12 | + SelectContent, | |
| 13 | + SelectItem, | |
| 14 | + SelectTrigger, | |
| 15 | + SelectValue, | |
| 16 | +} from "@/components/ui/select"; | |
| 10 | 17 | import { useSession } from "@/lib/session-context"; |
| 11 | 18 | import { convexApi } from "@/lib/convex-api"; |
| 12 | 19 | import type { Id } from "@executor/database/convex/_generated/dataModel"; |
| @@ -159,18 +166,22 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 146 unmodified lines | ||
| 159 | 166 | type="email" |
| 160 | 167 | disabled={!canManageMembers || inviteState === "sending"} |
| 161 | 168 | /> |
| 162 | − <select | |
| 163 | − className="h-9 rounded-md border border-input bg-background px-3 text-sm" | |
| 169 | + <Select | |
| 164 | 170 | value={inviteRole} |
| 165 | − onChange={(event) => setInviteRole(event.target.value as Role)} | |
| 171 | + onValueChange={(value) => setInviteRole(value as Role)} | |
| 166 | 172 | disabled={!canManageMembers || inviteState === "sending"} |
| 167 | 173 | > |
| 168 | − {ROLE_OPTIONS.map((role) => ( | |
| 169 | − <option key={role} value={role}> | |
| 170 | − {role} | |
| 171 | − </option> | |
| 172 | − ))} | |
| 173 | − </select> | |
| 174 | + <SelectTrigger className="h-9 min-w-40"> | |
| 175 | + <SelectValue /> | |
| 176 | + </SelectTrigger> | |
| 177 | + <SelectContent> | |
| 178 | + {ROLE_OPTIONS.map((role) => ( | |
| 179 | + <SelectItem key={role} value={role}> | |
| 180 | + {role} | |
| 181 | + </SelectItem> | |
| 182 | + ))} | |
| 183 | + </SelectContent> | |
| 184 | + </Select> | |
| 174 | 185 | <Button |
| 175 | 186 | type="submit" |
| 176 | 187 | disabled={!canManageMembers || inviteState === "sending" || inviteEmail.trim().length === 0} |
| @@ -207,11 +218,10 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 30 unmodified lines | ||
| 207 | 218 | <p className="text-xs text-muted-foreground">Status: {member.status}</p> |
| 208 | 219 | </div> |
| 209 | 220 | <div className="flex flex-wrap items-center gap-2"> |
| 210 | − <select | |
| 211 | − className="h-8 rounded-md border border-input bg-background px-2 text-xs" | |
| 221 | + <Select | |
| 212 | 222 | value={member.role} |
| 213 | 223 | disabled={!canManageMembers || busyMemberAccountId === member.accountId} |
| 214 | − onChange={async (event) => { | |
| 224 | + onValueChange={async (value) => { | |
| 215 | 225 | setBusyMemberAccountId(member.accountId); |
| 216 | 226 | try { |
| 217 | 227 | if (!typedOrganizationId) { |
| @@ -220,7 +230,7 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 2 unmodified lines | ||
| 220 | 230 | await updateRole({ |
| 221 | 231 | organizationId: typedOrganizationId, |
| 222 | 232 | accountId: member.accountId, |
| 223 | − role: event.target.value as Role, | |
| 233 | + role: value as Role, | |
| 224 | 234 | sessionId: context?.sessionId ?? undefined, |
| 225 | 235 | }); |
| 226 | 236 | } finally { |
| @@ -228,12 +238,17 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 1 unmodified line | ||
| 228 | 238 | } |
| 229 | 239 | }} |
| 230 | 240 | > |
| 231 | − {ROLE_OPTIONS.map((role) => ( | |
| 232 | − <option key={role} value={role}> | |
| 233 | − {role} | |
| 234 | − </option> | |
| 235 | − ))} | |
| 236 | − </select> | |
| 241 | + <SelectTrigger className="h-8 min-w-36"> | |
| 242 | + <SelectValue /> | |
| 243 | + </SelectTrigger> | |
| 244 | + <SelectContent> | |
| 245 | + {ROLE_OPTIONS.map((role) => ( | |
| 246 | + <SelectItem key={role} value={role}> | |
| 247 | + {role} | |
| 248 | + </SelectItem> | |
| 249 | + ))} | |
| 250 | + </SelectContent> | |
| 251 | + </Select> | |
| 237 | 252 | |
| 238 | 253 | <Button |
| 239 | 254 | variant="outline" |
| @@ -96,7 +96,7 @@ export function SessionInfo() { · 95 unmodified lines | ||
| 96 | 96 | if (loading) { |
| 97 | 97 | return ( |
| 98 | 98 | <div className="border-t border-border px-3 py-2"> |
| 99 | − <span className="text-[11px] font-mono text-muted-foreground">Loading session...</span> | |
| 99 | + <span className="text-xs text-muted-foreground">Loading session...</span> | |
| 100 | 100 | </div> |
| 101 | 101 | ); |
| 102 | 102 | } |
| @@ -121,7 +121,7 @@ export function SessionInfo() { · 18 unmodified lines | ||
| 121 | 121 | referrerPolicy="no-referrer" |
| 122 | 122 | /> |
| 123 | 123 | ) : ( |
| 124 | − <span className="h-6 w-6 rounded-full border border-border bg-muted text-[10px] font-mono text-muted-foreground flex items-center justify-center"> | |
| 124 | + <span className="flex h-6 w-6 items-center justify-center rounded-full border border-border bg-muted text-xs text-muted-foreground"> | |
| 125 | 125 | {avatarInitial} |
| 126 | 126 | </span> |
| 127 | 127 | )} |
| @@ -41,7 +41,7 @@ export function TaskStatusBadge({ status }: { status: TaskStatus }) { · 40 unmodified lines | ||
| 41 | 41 | return ( |
| 42 | 42 | <Badge |
| 43 | 43 | variant="outline" |
| 44 | − className={cn("text-[10px] font-mono uppercase tracking-wider", config.className)} | |
| 44 | + className={cn("text-xs font-medium", config.className)} | |
| 45 | 45 | > |
| 46 | 46 | {status === "running" && ( |
| 47 | 47 | <span className="inline-block h-1.5 w-1.5 rounded-full bg-current mr-1.5 pulse-dot" /> |
| @@ -1,10 +1,9 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 4 | 4 | import { Send } from "lucide-react"; |
| 5 | 5 | import { useAction } from "convex/react"; |
| 6 | 6 | import { toast } from "sonner"; |
| 7 | −import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
| 8 | 7 | import { Button } from "@/components/ui/button"; |
| 9 | 8 | import { Input } from "@/components/ui/input"; |
| 10 | 9 | import { Label } from "@/components/ui/label"; |
| @@ -142,6 +141,8 @@ export function TaskComposer() { · 131 unmodified lines | ||
| 142 | 141 | : runtimeTargets[0]?.id ?? ""; |
| 143 | 142 | const selectedRuntime = runtimeTargets.find((runtime) => runtime.id === effectiveRuntimeId); |
| 144 | 143 | const showRuntimeSelector = runtimeTargets.length > 1; |
| 144 | + const isMac = useMemo(() => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent), []); | |
| 145 | + const modKey = isMac ? "⌘" : "Ctrl"; | |
| 145 | 146 | |
| 146 | 147 | useEffect(() => { |
| 147 | 148 | const draft = readCodeDraftForWorkspace(storageWorkspaceId); |
| @@ -178,7 +179,9 @@ export function TaskComposer() { · 30 unmodified lines | ||
| 178 | 179 | }; |
| 179 | 180 | }, [codeDraftStorageKey]); |
| 180 | 181 | |
| 181 | − const handleSubmit = async () => { | |
| 182 | + const handleSubmitRef = useRef<(() => void) | undefined>(undefined); | |
| 183 | + | |
| 184 | + const handleSubmit = useCallback(async () => { | |
| 182 | 185 | if (!context || !code.trim()) return; |
| 183 | 186 | setSubmitting(true); |
| 184 | 187 | try { |
| @@ -213,79 +216,91 @@ export function TaskComposer() { · 28 unmodified lines | ||
| 213 | 216 | } finally { |
| 214 | 217 | setSubmitting(false); |
| 215 | 218 | } |
| 216 | − }; | |
| 219 | + }, [context, code, effectiveRuntimeId, timeoutMs, createTask, submitting]); | |
| 220 | + | |
| 221 | + handleSubmitRef.current = handleSubmit; | |
| 222 | + | |
| 223 | + useEffect(() => { | |
| 224 | + const onKeyDown = (e: KeyboardEvent) => { | |
| 225 | + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { | |
| 226 | + e.preventDefault(); | |
| 227 | + handleSubmitRef.current?.(); | |
| 228 | + } | |
| 229 | + }; | |
| 230 | + window.addEventListener("keydown", onKeyDown); | |
| 231 | + return () => window.removeEventListener("keydown", onKeyDown); | |
| 232 | + }, []); | |
| 217 | 233 | |
| 218 | 234 | return ( |
| 219 | − <Card className="bg-card border-border"> | |
| 220 | − <CardHeader className="pb-3"> | |
| 221 | − <CardTitle className="text-sm font-medium">Editor</CardTitle> | |
| 222 | − </CardHeader> | |
| 223 | − <CardContent className="space-y-4"> | |
| 224 | − <div className="grid grid-cols-2 gap-3"> | |
| 225 | − <div className="space-y-1.5"> | |
| 226 | − {showRuntimeSelector ? <Label className="text-xs text-muted-foreground">Runtime</Label> : null} | |
| 227 | − {showRuntimeSelector ? ( | |
| 228 | − <Select value={effectiveRuntimeId} onValueChange={setRuntimeId}> | |
| 229 | − <SelectTrigger className="h-8 text-xs font-mono bg-background"> | |
| 230 | − <SelectValue /> | |
| 231 | − </SelectTrigger> | |
| 232 | − <SelectContent> | |
| 233 | − {runtimeTargets.map((r: RuntimeTargetDescriptor) => ( | |
| 234 | − <SelectItem key={r.id} value={r.id} className="text-xs"> | |
| 235 | − {r.label} | |
| 236 | − </SelectItem> | |
| 237 | − ))} | |
| 238 | − </SelectContent> | |
| 239 | − </Select> | |
| 240 | − ) : ( | |
| 241 | − null | |
| 242 | − )} | |
| 243 | − </div> | |
| 244 | − <div className="space-y-1.5"> | |
| 245 | − <Label className="text-xs text-muted-foreground"> | |
| 246 | − Timeout (ms) | |
| 247 | − </Label> | |
| 235 | + <div className="flex h-full min-h-0 flex-col overflow-hidden bg-background"> | |
| 236 | + {/* ── Toolbar ── */} | |
| 237 | + <div className="shrink-0 flex items-center justify-between gap-3 border-b border-border/40 px-4 py-2"> | |
| 238 | + <div className="flex items-center gap-3"> | |
| 239 | + <h3 className="text-sm font-medium">Editor</h3> | |
| 240 | + <span className="text-[10px] font-mono text-muted-foreground"> | |
| 241 | + {loadingTools | |
| 242 | + ? "Loading tool inventory..." | |
| 243 | + : `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded${typesUrl ? ", type defs ready" : ""}`} | |
| 244 | + </span> | |
| 245 | + </div> | |
| 246 | + <div className="flex items-center gap-3"> | |
| 247 | + {showRuntimeSelector ? ( | |
| 248 | + <Select value={effectiveRuntimeId} onValueChange={setRuntimeId}> | |
| 249 | + <SelectTrigger className="h-7 text-xs font-mono bg-background w-[160px]"> | |
| 250 | + <SelectValue /> | |
| 251 | + </SelectTrigger> | |
| 252 | + <SelectContent> | |
| 253 | + {runtimeTargets.map((r: RuntimeTargetDescriptor) => ( | |
| 254 | + <SelectItem key={r.id} value={r.id} className="text-xs"> | |
| 255 | + {r.label} | |
| 256 | + </SelectItem> | |
| 257 | + ))} | |
| 258 | + </SelectContent> | |
| 259 | + </Select> | |
| 260 | + ) : null} | |
| 261 | + <div className="flex items-center gap-1.5"> | |
| 262 | + <Label className="text-[10px] text-muted-foreground shrink-0">Timeout</Label> | |
| 248 | 263 | <Input |
| 249 | 264 | type="number" |
| 250 | 265 | value={timeoutMs} |
| 251 | 266 | onChange={(e) => setTimeoutMs(e.target.value)} |
| 252 | − className="h-8 text-xs font-mono bg-background" | |
| 267 | + className="h-7 text-xs font-mono bg-background w-[100px]" | |
| 253 | 268 | /> |
| 254 | 269 | </div> |
| 270 | + <Button | |
| 271 | + onClick={handleSubmit} | |
| 272 | + disabled={submitting || !code.trim()} | |
| 273 | + className="h-7 bg-primary text-primary-foreground hover:bg-primary/90 text-xs gap-1.5 px-3" | |
| 274 | + size="sm" | |
| 275 | + > | |
| 276 | + <Send className="h-3 w-3" /> | |
| 277 | + {submitting ? "Executing..." : "Execute"} | |
| 278 | + <kbd className="ml-1 pointer-events-none inline-flex items-center gap-0.5 rounded border border-primary-foreground/20 bg-primary-foreground/10 px-1 py-0.5 font-mono text-[10px] font-medium text-primary-foreground/70"> | |
| 279 | + {modKey}↵ | |
| 280 | + </kbd> | |
| 281 | + </Button> | |
| 255 | 282 | </div> |
| 256 | − <div className="space-y-1.5"> | |
| 257 | − <div className="flex items-center justify-between gap-2"> | |
| 258 | − <Label className="text-xs text-muted-foreground">Code</Label> | |
| 259 | − <span className="text-[10px] font-mono text-muted-foreground"> | |
| 260 | − {loadingTools | |
| 261 | − ? "Loading tool inventory..." | |
| 262 | − : `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded${typesUrl ? ", type defs ready" : ""}`} | |
| 263 | − </span> | |
| 264 | − </div> | |
| 265 | − <div className="rounded-md border border-border"> | |
| 266 | − <CodeEditor | |
| 267 | − value={code} | |
| 268 | − onChange={handleCodeChange} | |
| 269 | − tools={tools} | |
| 270 | − typesUrl={typesUrl} | |
| 271 | − stateStorageKey={editorViewStateStorageKey} | |
| 272 | − height="400px" | |
| 273 | − /> | |
| 274 | − </div> | |
| 283 | + </div> | |
| 284 | + | |
| 285 | + {/* ── Main content area ── */} | |
| 286 | + <div className="flex flex-1 min-h-0 overflow-hidden"> | |
| 287 | + {/* Code editor - fills available space */} | |
| 288 | + <div className="flex-1 min-w-0 min-h-0 flex flex-col" style={{ borderRight: lastExecution ? '1px solid hsl(var(--border) / 0.4)' : undefined }}> | |
| 289 | + <CodeEditor | |
| 290 | + value={code} | |
| 291 | + onChange={handleCodeChange} | |
| 292 | + tools={tools} | |
| 293 | + typesUrl={typesUrl} | |
| 294 | + stateStorageKey={editorViewStateStorageKey} | |
| 295 | + className="h-full" | |
| 296 | + height="100%" | |
| 297 | + /> | |
| 275 | 298 | </div> |
| 276 | − <Button | |
| 277 | − onClick={handleSubmit} | |
| 278 | − disabled={submitting || !code.trim()} | |
| 279 | − className="w-full bg-primary text-primary-foreground hover:bg-primary/90 h-9" | |
| 280 | − size="sm" | |
| 281 | − > | |
| 282 | − <Send className="h-3.5 w-3.5 mr-2" /> | |
| 283 | − {submitting ? "Executing..." : "Execute Task"} | |
| 284 | − </Button> | |
| 285 | 299 | |
| 300 | + {/* Result panel - right side */} | |
| 286 | 301 | {lastExecution && ( |
| 287 | − <div className="space-y-2"> | |
| 288 | − <div className="flex items-center justify-between gap-2"> | |
| 302 | + <div className="w-[400px] lg:w-[480px] shrink-0 flex flex-col min-h-0 overflow-hidden"> | |
| 303 | + <div className="shrink-0 flex items-center justify-between gap-2 border-b border-border/30 px-3 py-2"> | |
| 289 | 304 | <span className="text-[10px] uppercase tracking-widest text-muted-foreground"> |
| 290 | 305 | Last execution |
| 291 | 306 | </span> |
| @@ -293,36 +308,36 @@ export function TaskComposer() { · 1 unmodified line | ||
| 293 | 308 | {lastExecution.status} - {lastExecution.taskId} |
| 294 | 309 | </span> |
| 295 | 310 | </div> |
| 296 | − | |
| 297 | − {lastExecution.result !== undefined && ( | |
| 298 | − <div> | |
| 299 | − <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2"> | |
| 300 | − Returned result | |
| 301 | − </span> | |
| 302 | − <FormattedCodeBlock | |
| 303 | − content={lastExecution.result} | |
| 304 | − language="json" | |
| 305 | − className="min-h-40 max-h-[65vh] overflow-auto resize-y" | |
| 306 | − /> | |
| 307 | − </div> | |
| 308 | − )} | |
| 309 | − | |
| 310 | − {lastExecution.error && ( | |
| 311 | − <div> | |
| 312 | − <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2"> | |
| 313 | − Error | |
| 314 | − </span> | |
| 315 | − <FormattedCodeBlock | |
| 316 | − content={lastExecution.error} | |
| 317 | − language="text" | |
| 318 | − tone="red" | |
| 319 | − className="min-h-32 max-h-[50vh] overflow-auto resize-y" | |
| 320 | − /> | |
| 321 | − </div> | |
| 322 | − )} | |
| 311 | + <div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3"> | |
| 312 | + {lastExecution.result !== undefined && ( | |
| 313 | + <div> | |
| 314 | + <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2"> | |
| 315 | + Returned result | |
| 316 | + </span> | |
| 317 | + <FormattedCodeBlock | |
| 318 | + content={lastExecution.result} | |
| 319 | + language="json" | |
| 320 | + className="min-h-40 max-h-full overflow-auto resize-y" | |
| 321 | + /> | |
| 322 | + </div> | |
| 323 | + )} | |
| 324 | + {lastExecution.error && ( | |
| 325 | + <div> | |
| 326 | + <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2"> | |
| 327 | + Error | |
| 328 | + </span> | |
| 329 | + <FormattedCodeBlock | |
| 330 | + content={lastExecution.error} | |
| 331 | + language="text" | |
| 332 | + tone="red" | |
| 333 | + className="min-h-32 max-h-full overflow-auto resize-y" | |
| 334 | + /> | |
| 335 | + </div> | |
| 336 | + )} | |
| 337 | + </div> | |
| 323 | 338 | </div> |
| 324 | 339 | )} |
| 325 | − </CardContent> | |
| 326 | − </Card> | |
| 340 | + </div> | |
| 341 | + </div> | |
| 327 | 342 | ); |
| 328 | 343 | } |
| @@ -99,9 +99,10 @@ export function TaskDetail({ · 98 unmodified lines | ||
| 99 | 99 | <Card className="bg-card border-border"> |
| 100 | 100 | <CardHeader className="pb-3"> |
| 101 | 101 | <div className="flex items-center justify-between"> |
| 102 | − <CardTitle className="text-sm font-medium font-mono truncate pr-4"> | |
| 103 | − {liveTask.id} | |
| 104 | − </CardTitle> | |
| 102 | + <div className="min-w-0 pr-4"> | |
| 103 | + <CardTitle className="truncate text-sm font-semibold">Task details</CardTitle> | |
| 104 | + <p className="truncate text-xs text-muted-foreground">{liveTask.id}</p> | |
| 105 | + </div> | |
| 105 | 106 | <Button |
| 106 | 107 | variant="ghost" |
| 107 | 108 | size="icon" |
| @@ -116,19 +117,19 @@ export function TaskDetail({ · 8 unmodified lines | ||
| 116 | 117 | <div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> |
| 117 | 118 | {[ |
| 118 | 119 | { label: "Status", value: <TaskStatusBadge status={liveTask.status} /> }, |
| 119 | − { label: "Runtime", value: <span className="font-mono text-xs">{runtimeLabel ?? liveTask.runtimeId}</span> }, | |
| 120 | − { label: "Duration", value: <span className="font-mono text-xs">{duration}</span> }, | |
| 120 | + { label: "Runtime", value: <span className="text-sm">{runtimeLabel ?? liveTask.runtimeId}</span> }, | |
| 121 | + { label: "Duration", value: <span className="text-sm">{duration}</span> }, | |
| 121 | 122 | { |
| 122 | 123 | label: "Exit Code", |
| 123 | 124 | value: ( |
| 124 | − <span className={cn("font-mono text-xs", liveTask.exitCode === 0 ? "text-terminal-green" : liveTask.exitCode ? "text-terminal-red" : "text-muted-foreground")}> | |
| 125 | + <span className={cn("text-sm", liveTask.exitCode === 0 ? "text-primary" : liveTask.exitCode ? "text-destructive" : "text-muted-foreground")}> | |
| 125 | 126 | {liveTask.exitCode ?? "—"} |
| 126 | 127 | </span> |
| 127 | 128 | ), |
| 128 | 129 | }, |
| 129 | 130 | ].map((item) => ( |
| 130 | 131 | <div key={item.label}> |
| 131 | − <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-1"> | |
| 132 | + <span className="mb-1 block text-xs font-medium text-muted-foreground"> | |
| 132 | 133 | {item.label} |
| 133 | 134 | </span> |
| 134 | 135 | {item.value} |
| @@ -141,10 +142,10 @@ export function TaskDetail({ · 6 unmodified lines | ||
| 141 | 142 | <Separator /> |
| 142 | 143 | <div className="space-y-2.5"> |
| 143 | 144 | <div className="flex items-center justify-between"> |
| 144 | − <span className="text-[10px] uppercase tracking-widest text-terminal-amber"> | |
| 145 | + <span className="text-xs font-medium text-primary"> | |
| 145 | 146 | Pending approvals |
| 146 | 147 | </span> |
| 147 | − <span className="text-[10px] font-mono bg-terminal-amber/10 text-terminal-amber px-1.5 py-0.5 rounded"> | |
| 148 | + <span className="rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary"> | |
| 148 | 149 | {pendingApprovals.length} |
| 149 | 150 | </span> |
| 150 | 151 | </div> |
| @@ -154,14 +155,14 @@ export function TaskDetail({ · 3 unmodified lines | ||
| 154 | 155 | return ( |
| 155 | 156 | <div |
| 156 | 157 | key={approval.id} |
| 157 | − className="rounded-md border border-terminal-amber/30 bg-terminal-amber/6 p-3 space-y-2" | |
| 158 | + className="space-y-2 rounded-md border border-primary/20 bg-primary/5 p-3" | |
| 158 | 159 | > |
| 159 | 160 | <div className="flex items-center justify-between gap-3"> |
| 160 | 161 | <div className="min-w-0"> |
| 161 | − <p className="text-xs font-mono text-foreground truncate"> | |
| 162 | + <p className="truncate text-sm font-medium text-foreground"> | |
| 162 | 163 | {approval.toolPath} |
| 163 | 164 | </p> |
| 164 | − <p className="text-[10px] text-muted-foreground"> | |
| 165 | + <p className="text-xs text-muted-foreground"> | |
| 165 | 166 | Requested {formatDate(approval.createdAt)} |
| 166 | 167 | </p> |
| 167 | 168 | </div> |
| @@ -169,7 +170,7 @@ export function TaskDetail({ · 1 unmodified line | ||
| 169 | 170 | <Button |
| 170 | 171 | variant="outline" |
| 171 | 172 | size="sm" |
| 172 | − className="h-7 text-[11px] border-terminal-green/30 text-terminal-green hover:bg-terminal-green/10" | |
| 173 | + className="h-7 border-primary/30 bg-primary/10 text-primary hover:bg-primary/20" | |
| 173 | 174 | disabled={resolvingApprovalId !== null} |
| 174 | 175 | onClick={() => |
| 175 | 176 | void handleResolveApproval( |
| @@ -185,7 +186,7 @@ export function TaskDetail({ · 9 unmodified lines | ||
| 185 | 186 | <Button |
| 186 | 187 | variant="outline" |
| 187 | 188 | size="sm" |
| 188 | − className="h-7 text-[11px] border-terminal-red/30 text-terminal-red hover:bg-terminal-red/10" | |
| 189 | + className="h-7 border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/20" | |
| 189 | 190 | disabled={resolvingApprovalId !== null} |
| 190 | 191 | onClick={() => |
| 191 | 192 | void handleResolveApproval( |
| @@ -217,7 +218,7 @@ export function TaskDetail({ · 25 unmodified lines | ||
| 217 | 218 | <Separator /> |
| 218 | 219 | |
| 219 | 220 | <div> |
| 220 | − <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-2"> | |
| 221 | + <span className="mb-2 block text-xs font-medium text-muted-foreground"> | |
| 221 | 222 | Code |
| 222 | 223 | </span> |
| 223 | 224 | <FormattedCodeBlock |
| @@ -229,7 +230,7 @@ export function TaskDetail({ · 5 unmodified lines | ||
| 229 | 230 | |
| 230 | 231 | {liveResult && ( |
| 231 | 232 | <div> |
| 232 | − <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2"> | |
| 233 | + <span className="mb-2 block text-xs font-medium text-primary"> | |
| 233 | 234 | Result |
| 234 | 235 | </span> |
| 235 | 236 | <FormattedCodeBlock |
| @@ -242,7 +243,7 @@ export function TaskDetail({ · 6 unmodified lines | ||
| 242 | 243 | |
| 243 | 244 | {liveTask.error && ( |
| 244 | 245 | <div> |
| 245 | − <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2"> | |
| 246 | + <span className="mb-2 block text-xs font-medium text-destructive"> | |
| 246 | 247 | Error |
| 247 | 248 | </span> |
| 248 | 249 | <FormattedCodeBlock |
| @@ -34,15 +34,15 @@ export function TaskListItem({ task, selected, onClick, runtimeLabel = task.runt · 33 unmodified lines | ||
| 34 | 34 | > |
| 35 | 35 | <div className="flex-1 min-w-0"> |
| 36 | 36 | <div className="flex items-center gap-2"> |
| 37 | − <span className="text-xs font-mono text-foreground truncate"> | |
| 37 | + <span className="truncate text-sm font-medium text-foreground"> | |
| 38 | 38 | {task.id} |
| 39 | 39 | </span> |
| 40 | 40 | </div> |
| 41 | 41 | <div className="flex items-center gap-2 mt-0.5"> |
| 42 | − <span className="text-[10px] font-mono text-muted-foreground"> | |
| 42 | + <span className="text-xs text-muted-foreground"> | |
| 43 | 43 | {runtimeLabel} |
| 44 | 44 | </span> |
| 45 | − <span className="text-[10px] text-muted-foreground"> | |
| 45 | + <span className="text-xs text-muted-foreground"> | |
| 46 | 46 | {formatDate(task.createdAt)} |
| 47 | 47 | </span> |
| 48 | 48 | </div> |
| @@ -4,7 +4,6 @@ import { useCallback } from "react"; · 3 unmodified lines | ||
| 4 | 4 | import { useQueryStates } from "nuqs"; |
| 5 | 5 | import { useNavigate } from "@/lib/router"; |
| 6 | 6 | import { ShieldCheck } from "lucide-react"; |
| 7 | −import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
| 8 | 7 | import { Button } from "@/components/ui/button"; |
| 9 | 8 | import { Skeleton } from "@/components/ui/skeleton"; |
| 10 | 9 | import { TaskDetail } from "@/components/tasks/task-detail"; |
| @@ -59,83 +58,91 @@ export function TasksView() { · 48 unmodified lines | ||
| 59 | 58 | |
| 60 | 59 | if (sessionLoading) { |
| 61 | 60 | return ( |
| 62 | − <div className="p-4 md:p-6 lg:p-8 space-y-6"> | |
| 63 | − <Skeleton className="h-8 w-48" /> | |
| 64 | − <Skeleton className="h-64" /> | |
| 61 | + <div className="flex h-full min-h-0 overflow-hidden bg-background"> | |
| 62 | + <div className="w-80 shrink-0 border-r border-border/40 p-3 space-y-3"> | |
| 63 | + <Skeleton className="h-8 w-full" /> | |
| 64 | + {Array.from({ length: 4 }).map((_, i) => ( | |
| 65 | + <Skeleton key={i} className="h-14" /> | |
| 66 | + ))} | |
| 67 | + </div> | |
| 68 | + <div className="flex-1 p-4"> | |
| 69 | + <Skeleton className="h-64" /> | |
| 70 | + </div> | |
| 65 | 71 | </div> |
| 66 | 72 | ); |
| 67 | 73 | } |
| 68 | 74 | |
| 69 | 75 | return ( |
| 70 | − <div className="flex h-full min-h-0 flex-col p-4 md:p-6 lg:p-8"> | |
| 71 | − <div className="mb-4 flex items-center justify-end gap-2"> | |
| 72 | − <Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => navigate("/approvals")}> | |
| 73 | − <ShieldCheck className="h-3.5 w-3.5 mr-1.5" /> | |
| 74 | − {pendingApprovals.length} pending | |
| 75 | − </Button> | |
| 76 | − </div> | |
| 77 | − | |
| 78 | − <div className="flex min-h-0 flex-1 flex-col"> | |
| 79 | − <div className="grid h-full min-h-0 gap-6 lg:grid-cols-[minmax(360px,440px)_1fr]"> | |
| 80 | − <Card className="rounded-none bg-card/40 border-border/50 min-h-0 flex flex-col"> | |
| 81 | − <CardHeader className="pb-3"> | |
| 82 | − <CardTitle className="text-sm font-medium flex items-center gap-2"> | |
| 83 | − Task History | |
| 84 | − {tasks && ( | |
| 85 | − <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1.5 py-0.5 rounded"> | |
| 86 | − {taskItems.length} | |
| 87 | − </span> | |
| 88 | − )} | |
| 89 | − </CardTitle> | |
| 90 | − </CardHeader> | |
| 91 | − <CardContent className="pt-0 min-h-0 flex-1"> | |
| 92 | − {tasksLoading ? ( | |
| 93 | − <div className="space-y-2"> | |
| 94 | − {Array.from({ length: 4 }).map((_, i) => ( | |
| 95 | − <Skeleton key={i} className="h-14" /> | |
| 96 | − ))} | |
| 97 | − </div> | |
| 98 | − ) : taskItems.length === 0 ? ( | |
| 99 | − <div className="flex flex-col items-center justify-center py-12 text-sm text-muted-foreground gap-2"> | |
| 100 | − <p>No tasks yet.</p> | |
| 101 | − </div> | |
| 102 | − ) : ( | |
| 103 | − <div className="h-full overflow-y-auto space-y-1 pr-1"> | |
| 104 | − {taskItems.map((task: TaskRecord) => ( | |
| 105 | − <TaskListItem | |
| 106 | − key={task.id} | |
| 107 | − task={task} | |
| 108 | − selected={task.id === selectedId} | |
| 109 | − runtimeLabel={getTaskRuntimeLabel(task.runtimeId, runtimeItems)} | |
| 110 | − onClick={() => selectTask(task.id)} | |
| 111 | − /> | |
| 112 | − ))} | |
| 113 | − </div> | |
| 76 | + <div className="flex h-full min-h-0 overflow-hidden bg-background"> | |
| 77 | + {/* ── Left sidebar: task list ── */} | |
| 78 | + <aside className="flex h-full w-80 shrink-0 flex-col border-r border-border/40 bg-card/30 lg:w-[360px]"> | |
| 79 | + {/* Sidebar header */} | |
| 80 | + <div className="shrink-0 border-b border-border/30 px-3 py-2.5"> | |
| 81 | + <div className="flex items-center justify-between"> | |
| 82 | + <div className="flex items-center gap-2"> | |
| 83 | + <h3 className="text-sm font-medium leading-none">Tasks</h3> | |
| 84 | + {tasks && ( | |
| 85 | + <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1.5 py-0.5 rounded"> | |
| 86 | + {taskItems.length} | |
| 87 | + </span> | |
| 114 | 88 | )} |
| 115 | − </CardContent> | |
| 116 | − </Card> | |
| 117 | − | |
| 118 | − <div className="min-h-0"> | |
| 119 | − {selectedTask && context ? ( | |
| 120 | − <TaskDetail | |
| 121 | − task={selectedTask} | |
| 122 | − workspaceId={context.workspaceId} | |
| 123 | − sessionId={context?.sessionId} | |
| 124 | − runtimeLabel={getTaskRuntimeLabel(selectedTask.runtimeId, runtimeItems)} | |
| 125 | − pendingApprovals={selectedTaskApprovals} | |
| 126 | − onClose={() => selectTask(null)} | |
| 127 | − /> | |
| 128 | − ) : ( | |
| 129 | − <Card className="rounded-none bg-card/40 border-border/50 h-full"> | |
| 130 | − <CardContent className="flex items-center justify-center py-24"> | |
| 131 | − <p className="text-sm text-muted-foreground"> | |
| 132 | − Select a task to view logs, output, and approval actions | |
| 133 | − </p> | |
| 134 | − </CardContent> | |
| 135 | − </Card> | |
| 136 | − )} | |
| 89 | + </div> | |
| 90 | + <Button variant="outline" size="sm" className="h-7 text-[10px] gap-1" onClick={() => navigate("/approvals")}> | |
| 91 | + <ShieldCheck className="h-3 w-3" /> | |
| 92 | + {pendingApprovals.length} pending | |
| 93 | + </Button> | |
| 137 | 94 | </div> |
| 138 | 95 | </div> |
| 96 | + | |
| 97 | + {/* Task list */} | |
| 98 | + <div className="flex-1 min-h-0 overflow-y-auto"> | |
| 99 | + {tasksLoading ? ( | |
| 100 | + <div className="space-y-2 p-3"> | |
| 101 | + {Array.from({ length: 4 }).map((_, i) => ( | |
| 102 | + <Skeleton key={i} className="h-14" /> | |
| 103 | + ))} | |
| 104 | + </div> | |
| 105 | + ) : taskItems.length === 0 ? ( | |
| 106 | + <div className="flex flex-col items-center justify-center py-16 text-sm text-muted-foreground gap-2 px-4"> | |
| 107 | + <p>No tasks yet.</p> | |
| 108 | + <p className="text-[11px] text-muted-foreground/60"> | |
| 109 | + Execute a task from the editor to see it here. | |
| 110 | + </p> | |
| 111 | + </div> | |
| 112 | + ) : ( | |
| 113 | + <div className="p-2 space-y-1"> | |
| 114 | + {taskItems.map((task: TaskRecord) => ( | |
| 115 | + <TaskListItem | |
| 116 | + key={task.id} | |
| 117 | + task={task} | |
| 118 | + selected={task.id === selectedId} | |
| 119 | + runtimeLabel={getTaskRuntimeLabel(task.runtimeId, runtimeItems)} | |
| 120 | + onClick={() => selectTask(task.id)} | |
| 121 | + /> | |
| 122 | + ))} | |
| 123 | + </div> | |
| 124 | + )} | |
| 125 | + </div> | |
| 126 | + </aside> | |
| 127 | + | |
| 128 | + {/* ── Right content: task detail ── */} | |
| 129 | + <div className="flex-1 min-w-0 max-h-screen overflow-y-auto bg-background/50"> | |
| 130 | + {selectedTask && context ? ( | |
| 131 | + <TaskDetail | |
| 132 | + task={selectedTask} | |
| 133 | + workspaceId={context.workspaceId} | |
| 134 | + sessionId={context?.sessionId} | |
| 135 | + runtimeLabel={getTaskRuntimeLabel(selectedTask.runtimeId, runtimeItems)} | |
| 136 | + pendingApprovals={selectedTaskApprovals} | |
| 137 | + onClose={() => selectTask(null)} | |
| 138 | + /> | |
| 139 | + ) : ( | |
| 140 | + <div className="flex h-full items-center justify-center"> | |
| 141 | + <p className="text-sm text-muted-foreground"> | |
| 142 | + Select a task to view logs, output, and approval actions | |
| 143 | + </p> | |
| 144 | + </div> | |
| 145 | + )} | |
| 139 | 146 | </div> |
| 140 | 147 | </div> |
| 141 | 148 | ); |
| @@ -85,9 +85,8 @@ export function AddSourceDialog({ · 84 unmodified lines | ||
| 85 | 85 | trigger?: ReactNode; |
| 86 | 86 | }) { |
| 87 | 87 | const { context } = useSession(); |
| 88 | − const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); | |
| 88 | + const upsertToolSource = useAction(convexApi.workspace.upsertToolSource); | |
| 89 | 89 | const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); |
| 90 | − const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); | |
| 91 | 90 | const previewOpenApiSourceUpgrade = useAction(convexApi.executorNode.previewOpenApiSourceUpgrade); |
| 92 | 91 | const credentials = useQuery( |
| 93 | 92 | convexApi.workspace.listCredentials, |
| @@ -218,7 +217,6 @@ export function AddSourceDialog({ · 124 unmodified lines | ||
| 218 | 217 | sourceToEdit, |
| 219 | 218 | credentialsLoading, |
| 220 | 219 | upsertToolSource, |
| 221 | − upsertCredential, | |
| 222 | 220 | form: { |
| 223 | 221 | name: form.name, |
| 224 | 222 | endpoint: form.endpoint, |
| @@ -1,4 +1,3 @@ | ||
| 1 | −import { sourceKeyForSource } from "@/lib/tools/source-helpers"; | |
| 2 | 1 | import type { |
| 3 | 2 | AnonymousContext, |
| 4 | 3 | CredentialRecord, |
| @@ -18,16 +17,12 @@ type UpsertToolSourceFn = (args: { · 13 unmodified lines | ||
| 18 | 17 | name: string; |
| 19 | 18 | type: SourceType; |
| 20 | 19 | config: Record<string, unknown>; |
| 21 | −}) => Promise<unknown>; | |
| 22 | − | |
| 23 | −type UpsertCredentialFn = (args: { | |
| 24 | − id?: CredentialRecord["id"]; | |
| 25 | − workspaceId: AnonymousContext["workspaceId"]; | |
| 26 | − sessionId: AnonymousContext["sessionId"]; | |
| 27 | − scopeType?: CredentialScope; | |
| 28 | − sourceKey: string; | |
| 29 | − accountId?: AnonymousContext["accountId"]; | |
| 30 | − secretJson: Record<string, unknown>; | |
| 20 | + credential?: { | |
| 21 | + id?: CredentialRecord["id"]; | |
| 22 | + scopeType?: CredentialScope; | |
| 23 | + accountId?: AnonymousContext["accountId"]; | |
| 24 | + secretJson: Record<string, unknown>; | |
| 25 | + }; | |
| 31 | 26 | }) => Promise<unknown>; |
| 32 | 27 | |
| 33 | 28 | type SaveFormSnapshot = { |
| @@ -63,46 +58,29 @@ export async function saveSourceWithCredentials({ · 29 unmodified lines | ||
| 63 | 58 | form, |
| 64 | 59 | credentialsLoading, |
| 65 | 60 | upsertToolSource, |
| 66 | − upsertCredential, | |
| 67 | 61 | }: { |
| 68 | 62 | context: AnonymousContext; |
| 69 | 63 | sourceToEdit?: ToolSourceRecord; |
| 70 | 64 | form: SaveFormSnapshot; |
| 71 | 65 | credentialsLoading: boolean; |
| 72 | 66 | upsertToolSource: UpsertToolSourceFn; |
| 73 | − upsertCredential: UpsertCredentialFn; | |
| 74 | 67 | }): Promise<{ source: ToolSourceRecord; connected: boolean }> { |
| 75 | 68 | const authConfig = form.type === "openapi" || form.type === "graphql" || form.type === "mcp" |
| 76 | 69 | ? form.buildAuthConfig() |
| 77 | 70 | : undefined; |
| 78 | 71 | |
| 79 | − const config = createCustomSourceConfig({ | |
| 80 | − type: form.type, | |
| 81 | − endpoint: form.endpoint.trim(), | |
| 82 | − baseUrl: form.baseUrl, | |
| 83 | − auth: authConfig, | |
| 84 | − mcpTransport: form.mcpTransport, | |
| 85 | − accountId: context.accountId, | |
| 86 | − }); | |
| 87 | − | |
| 88 | − const created = await upsertToolSource({ | |
| 89 | − ...(sourceToEdit ? { id: sourceToEdit.id } : {}), | |
| 90 | − workspaceId: context.workspaceId, | |
| 91 | − sessionId: context.sessionId, | |
| 92 | − scopeType: form.scopeType, | |
| 93 | − name: form.name.trim(), | |
| 94 | − type: form.type, | |
| 95 | − config, | |
| 96 | − }) as ToolSourceRecord; | |
| 97 | − | |
| 98 | 72 | let linkedCredential = false; |
| 73 | + let credentialPayload: | |
| 74 | + | { | |
| 75 | + id?: CredentialRecord["id"]; | |
| 76 | + scopeType: CredentialScope; | |
| 77 | + accountId?: AnonymousContext["accountId"]; | |
| 78 | + secretJson: Record<string, unknown>; | |
| 79 | + } | |
| 80 | + | undefined; | |
| 99 | 81 | |
| 100 | 82 | if ((form.type === "openapi" || form.type === "graphql" || form.type === "mcp") && form.authType !== "none") { |
| 101 | 83 | const credentialScopeType = credentialScopeTypeForAuthScope(form.authScope, form.scopeType); |
| 102 | − const sourceKey = sourceKeyForSource(created); | |
| 103 | − if (!sourceKey) { | |
| 104 | − throw new Error("Failed to resolve source key for credentials"); | |
| 105 | − } | |
| 106 | 84 | |
| 107 | 85 | if (form.authScope === "account" && !context.accountId) { |
| 108 | 86 | throw new Error("Account credentials require an authenticated account"); |
| @@ -119,30 +97,44 @@ export async function saveSourceWithCredentials({ · 10 unmodified lines | ||
| 119 | 97 | throw new Error(secret.error ?? "Credential values are required"); |
| 120 | 98 | } |
| 121 | 99 | |
| 122 | − await upsertCredential({ | |
| 100 | + credentialPayload = { | |
| 123 | 101 | ...(form.existingScopedCredential ? { id: form.existingScopedCredential.id } : {}), |
| 124 | − workspaceId: context.workspaceId, | |
| 125 | − sessionId: context.sessionId, | |
| 126 | 102 | scopeType: credentialScopeType, |
| 127 | − sourceKey, | |
| 128 | 103 | ...(credentialScopeType === "account" ? { accountId: context.accountId } : {}), |
| 129 | 104 | secretJson: secret.value, |
| 130 | − }); | |
| 105 | + }; | |
| 131 | 106 | linkedCredential = true; |
| 132 | 107 | } else if (form.existingScopedCredential) { |
| 133 | 108 | if (!existingCredentialMatchesAuthType(form.existingScopedCredential, form.authType)) { |
| 134 | − // Existing credential doesn't match — save source without credentials. | |
| 135 | 109 | linkedCredential = false; |
| 136 | 110 | } else { |
| 137 | 111 | linkedCredential = true; |
| 138 | 112 | } |
| 139 | 113 | } else { |
| 140 | − // No credentials entered and none saved — save source without credentials. | |
| 141 | − // The caller will show a warning toast. | |
| 142 | 114 | linkedCredential = false; |
| 143 | 115 | } |
| 144 | 116 | } |
| 145 | 117 | |
| 118 | + const config = createCustomSourceConfig({ | |
| 119 | + type: form.type, | |
| 120 | + endpoint: form.endpoint.trim(), | |
| 121 | + baseUrl: form.baseUrl, | |
| 122 | + auth: authConfig, | |
| 123 | + mcpTransport: form.mcpTransport, | |
| 124 | + accountId: context.accountId, | |
| 125 | + }); | |
| 126 | + | |
| 127 | + const created = await upsertToolSource({ | |
| 128 | + ...(sourceToEdit ? { id: sourceToEdit.id } : {}), | |
| 129 | + workspaceId: context.workspaceId, | |
| 130 | + sessionId: context.sessionId, | |
| 131 | + scopeType: form.scopeType, | |
| 132 | + name: form.name.trim(), | |
| 133 | + type: form.type, | |
| 134 | + config, | |
| 135 | + ...(credentialPayload ? { credential: credentialPayload } : {}), | |
| 136 | + }) as ToolSourceRecord; | |
| 137 | + | |
| 146 | 138 | return { |
| 147 | 139 | source: created, |
| 148 | 140 | connected: linkedCredential, |
| @@ -30,10 +30,10 @@ import type { · 29 unmodified lines | ||
| 30 | 30 | } from "@/lib/types"; |
| 31 | 31 | import { |
| 32 | 32 | connectionDisplayName, |
| 33 | − parseHeaderOverrideEntries, | |
| 34 | − parseHeaderOverrideRows, | |
| 35 | − serializeHeaderOverrideRows, | |
| 36 | − type HeaderOverrideEntry, | |
| 33 | + parseAdditionalHeaderEntries, | |
| 34 | + parseAdditionalHeaderRows, | |
| 35 | + serializeAdditionalHeaderRows, | |
| 36 | + type AdditionalHeaderEntry, | |
| 37 | 37 | } from "@/lib/credentials/source-helpers"; |
| 38 | 38 | import { sourceForCredentialKey } from "@/lib/tools/source-helpers"; |
| 39 | 39 | import { ConnectionAuthFields } from "./auth-fields"; |
| @@ -67,7 +67,7 @@ export function ConnectionFormDialog({ · 27 unmodified lines | ||
| 67 | 67 | const { context, clientConfig } = useSession(); |
| 68 | 68 | const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); |
| 69 | 69 | const [saving, setSaving] = useState(false); |
| 70 | − const [headerRows, setHeaderRows] = useState<HeaderOverrideEntry[]>(() => parseHeaderOverrideEntries("")); | |
| 70 | + const [headerRows, setHeaderRows] = useState<AdditionalHeaderEntry[]>(() => parseAdditionalHeaderEntries("")); | |
| 71 | 71 | const { |
| 72 | 72 | sourceKey, |
| 73 | 73 | scopeType, |
| @@ -80,7 +80,7 @@ export function ConnectionFormDialog({ · 6 unmodified lines | ||
| 80 | 80 | apiKeyValue, |
| 81 | 81 | basicUsername, |
| 82 | 82 | basicPassword, |
| 83 | − customHeadersText, | |
| 83 | + additionalHeadersText, | |
| 84 | 84 | sourceOptions, |
| 85 | 85 | connectionOptions, |
| 86 | 86 | compatibleConnectionOptions, |
| @@ -94,7 +94,7 @@ export function ConnectionFormDialog({ · 7 unmodified lines | ||
| 94 | 94 | setApiKeyValue, |
| 95 | 95 | setBasicUsername, |
| 96 | 96 | setBasicPassword, |
| 97 | − setCustomHeadersText, | |
| 97 | + setAdditionalHeadersText, | |
| 98 | 98 | handleSourceKeyChange, |
| 99 | 99 | } = useConnectionFormDialogForm({ |
| 100 | 100 | open, |
| @@ -125,8 +125,8 @@ export function ConnectionFormDialog({ · 24 unmodified lines | ||
| 125 | 125 | return; |
| 126 | 126 | } |
| 127 | 127 | |
| 128 | − setHeaderRows(parseHeaderOverrideEntries(customHeadersText)); | |
| 129 | − }, [open, customHeadersText]); | |
| 128 | + setHeaderRows(parseAdditionalHeaderEntries(additionalHeadersText)); | |
| 129 | + }, [open, additionalHeadersText]); | |
| 130 | 130 | |
| 131 | 131 | const handleSave = async () => { |
| 132 | 132 | if (!context) { |
| @@ -141,14 +141,14 @@ export function ConnectionFormDialog({ · 8 unmodified lines | ||
| 141 | 141 | return; |
| 142 | 142 | } |
| 143 | 143 | |
| 144 | − const parsedHeaders = parseHeaderOverrideRows(headerRows); | |
| 144 | + const parsedHeaders = parseAdditionalHeaderRows(headerRows); | |
| 145 | 145 | if (!parsedHeaders.value) { |
| 146 | − toast.error(parsedHeaders.error ?? "Invalid header overrides"); | |
| 146 | + toast.error(parsedHeaders.error ?? "Invalid additional headers"); | |
| 147 | 147 | return; |
| 148 | 148 | } |
| 149 | 149 | |
| 150 | − const serializedHeaders = serializeHeaderOverrideRows(headerRows); | |
| 151 | − setCustomHeadersText(serializedHeaders); | |
| 150 | + const serializedHeaders = serializeAdditionalHeaderRows(headerRows); | |
| 151 | + setAdditionalHeadersText(serializedHeaders); | |
| 152 | 152 | |
| 153 | 153 | const linkExisting = !editing && connectionMode === "existing"; |
| 154 | 154 | if (linkExisting && !existingConnectionKey) { |
| @@ -185,7 +185,6 @@ export function ConnectionFormDialog({ · 30 unmodified lines | ||
| 185 | 185 | basicPassword, |
| 186 | 186 | apiKeyValue, |
| 187 | 187 | tokenValue, |
| 188 | − parsedHeaders: parsedHeaders.value, | |
| 189 | 188 | }); |
| 190 | 189 | |
| 191 | 190 | if (!secretResult.secretJson) { |
| @@ -200,6 +199,10 @@ export function ConnectionFormDialog({ · 8 unmodified lines | ||
| 200 | 199 | : scopeType === "organization" |
| 201 | 200 | ? "organization" |
| 202 | 201 | : "workspace"; |
| 202 | + const additionalHeaders = Object.entries(parsedHeaders.value).map(([name, value]) => ({ | |
| 203 | + name, | |
| 204 | + value, | |
| 205 | + })); | |
| 203 | 206 | |
| 204 | 207 | await upsertCredential({ |
| 205 | 208 | ...(editing ? { id: editing.id } : selectedExistingConnection ? { id: selectedExistingConnection.id } : {}), |
| @@ -209,6 +212,7 @@ export function ConnectionFormDialog({ · 3 unmodified lines | ||
| 209 | 212 | scopeType: credentialScopeType, |
| 210 | 213 | ...(credentialScopeType === "account" ? { accountId: accountId.trim() as typeof context.accountId } : {}), |
| 211 | 214 | secretJson: secretResult.secretJson, |
| 215 | + additionalHeaders, | |
| 212 | 216 | }); |
| 213 | 217 | |
| 214 | 218 | toast.success(connectionSuccessCopy(Boolean(editing), linkExisting)); |
| @@ -231,7 +235,7 @@ export function ConnectionFormDialog({ · 16 unmodified lines | ||
| 231 | 235 | }); |
| 232 | 236 | }; |
| 233 | 237 | |
| 234 | − const updateHeaderRow = (index: number, patch: Partial<HeaderOverrideEntry>) => { | |
| 238 | + const updateHeaderRow = (index: number, patch: Partial<AdditionalHeaderEntry>) => { | |
| 235 | 239 | setHeaderRows((current) => |
| 236 | 240 | current.map((entry, rowIndex) => { |
| 237 | 241 | if (rowIndex !== index) { |
| @@ -0,0 +1,441 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { type ChangeEvent, useEffect, useState } from "react"; | |
| 4 | +import { useAction } from "convex/react"; | |
| 5 | +import { toast } from "sonner"; | |
| 6 | +import { Button } from "@/components/ui/button"; | |
| 7 | +import { Input } from "@/components/ui/input"; | |
| 8 | +import { Label } from "@/components/ui/label"; | |
| 9 | +import { Badge } from "@/components/ui/badge"; | |
| 10 | +import { Plus, Trash2, X } from "lucide-react"; | |
| 11 | +import { | |
| 12 | + Select, | |
| 13 | + SelectContent, | |
| 14 | + SelectItem, | |
| 15 | + SelectTrigger, | |
| 16 | + SelectValue, | |
| 17 | +} from "@/components/ui/select"; | |
| 18 | +import { useSession } from "@/lib/session-context"; | |
| 19 | +import { convexApi } from "@/lib/convex-api"; | |
| 20 | +import type { | |
| 21 | + CredentialRecord, | |
| 22 | + SourceAuthProfile, | |
| 23 | + ToolSourceRecord, | |
| 24 | +} from "@/lib/types"; | |
| 25 | +import { | |
| 26 | + connectionDisplayName, | |
| 27 | + parseAdditionalHeaderEntries, | |
| 28 | + parseAdditionalHeaderRows, | |
| 29 | + serializeAdditionalHeaderRows, | |
| 30 | + type AdditionalHeaderEntry, | |
| 31 | +} from "@/lib/credentials/source-helpers"; | |
| 32 | +import { sourceForCredentialKey } from "@/lib/tools/source-helpers"; | |
| 33 | +import { ConnectionAuthFields } from "./auth-fields"; | |
| 34 | +import { type ConnectionMode } from "./form/dialog-state"; | |
| 35 | +import { useConnectionFormDialogForm } from "./form/dialog-form"; | |
| 36 | +import { | |
| 37 | + buildSecretJson, | |
| 38 | + connectionSubmitCopy, | |
| 39 | + connectionSuccessCopy, | |
| 40 | +} from "./form-save"; | |
| 41 | + | |
| 42 | +export function ConnectionFormPanel({ | |
| 43 | + editing, | |
| 44 | + initialSourceKey, | |
| 45 | + sources, | |
| 46 | + credentials, | |
| 47 | + sourceAuthProfiles, | |
| 48 | + loadingSourceNames = [], | |
| 49 | + onClose, | |
| 50 | + onSaved, | |
| 51 | +}: { | |
| 52 | + editing: CredentialRecord | null; | |
| 53 | + initialSourceKey?: string | null; | |
| 54 | + sources: ToolSourceRecord[]; | |
| 55 | + credentials: CredentialRecord[]; | |
| 56 | + sourceAuthProfiles: Record<string, SourceAuthProfile>; | |
| 57 | + loadingSourceNames?: string[]; | |
| 58 | + onClose: () => void; | |
| 59 | + onSaved?: () => void; | |
| 60 | +}) { | |
| 61 | + const { context, clientConfig } = useSession(); | |
| 62 | + const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); | |
| 63 | + const [saving, setSaving] = useState(false); | |
| 64 | + const [headerRows, setHeaderRows] = useState<AdditionalHeaderEntry[]>(() => parseAdditionalHeaderEntries("")); | |
| 65 | + const { | |
| 66 | + sourceKey, | |
| 67 | + scopeType, | |
| 68 | + scopePreset, | |
| 69 | + scope, | |
| 70 | + accountId, | |
| 71 | + connectionMode, | |
| 72 | + existingConnectionKey, | |
| 73 | + tokenValue, | |
| 74 | + apiKeyValue, | |
| 75 | + basicUsername, | |
| 76 | + basicPassword, | |
| 77 | + additionalHeadersText, | |
| 78 | + sourceOptions, | |
| 79 | + connectionOptions, | |
| 80 | + compatibleConnectionOptions, | |
| 81 | + selectedAuth, | |
| 82 | + authBadge, | |
| 83 | + setScopePreset, | |
| 84 | + setAccountId, | |
| 85 | + setConnectionMode, | |
| 86 | + setExistingConnectionKey, | |
| 87 | + setTokenValue, | |
| 88 | + setApiKeyValue, | |
| 89 | + setBasicUsername, | |
| 90 | + setBasicPassword, | |
| 91 | + setAdditionalHeadersText, | |
| 92 | + handleSourceKeyChange, | |
| 93 | + } = useConnectionFormDialogForm({ | |
| 94 | + open: true, | |
| 95 | + editing, | |
| 96 | + initialSourceKey, | |
| 97 | + sources, | |
| 98 | + credentials, | |
| 99 | + sourceAuthProfiles, | |
| 100 | + accountIdFallback: context?.accountId, | |
| 101 | + }); | |
| 102 | + | |
| 103 | + const storageCopy = clientConfig?.authProviderMode === "workos" | |
| 104 | + ? "Stored encrypted" | |
| 105 | + : "Stored locally on this machine"; | |
| 106 | + const selectedSource = sourceForCredentialKey(sources, sourceKey); | |
| 107 | + const selectedSourceName = selectedSource?.name; | |
| 108 | + const authDetectionPending = | |
| 109 | + !editing | |
| 110 | + && selectedAuth.type === "none" | |
| 111 | + && Boolean(selectedSource) | |
| 112 | + && Boolean(selectedSource?.type === "openapi" || selectedSource?.type === "graphql") | |
| 113 | + && selectedSource?.config.auth === undefined | |
| 114 | + && (selectedSourceName ? loadingSourceNames.includes(selectedSourceName) : false); | |
| 115 | + const detectedAuthLabel = authDetectionPending ? "Detecting..." : authBadge; | |
| 116 | + | |
| 117 | + useEffect(() => { | |
| 118 | + setHeaderRows(parseAdditionalHeaderEntries(additionalHeadersText)); | |
| 119 | + }, [additionalHeadersText]); | |
| 120 | + | |
| 121 | + const handleSave = async () => { | |
| 122 | + if (!context) { | |
| 123 | + return; | |
| 124 | + } | |
| 125 | + if (!sourceKey.trim()) { | |
| 126 | + toast.error("Choose an API source"); | |
| 127 | + return; | |
| 128 | + } | |
| 129 | + if (scope === "account" && !accountId.trim()) { | |
| 130 | + toast.error("Account ID is required for personal credentials"); | |
| 131 | + return; | |
| 132 | + } | |
| 133 | + | |
| 134 | + const parsedHeaders = parseAdditionalHeaderRows(headerRows); | |
| 135 | + if (!parsedHeaders.value) { | |
| 136 | + toast.error(parsedHeaders.error ?? "Invalid additional headers"); | |
| 137 | + return; | |
| 138 | + } | |
| 139 | + | |
| 140 | + const serializedHeaders = serializeAdditionalHeaderRows(headerRows); | |
| 141 | + setAdditionalHeadersText(serializedHeaders); | |
| 142 | + | |
| 143 | + const linkExisting = !editing && connectionMode === "existing"; | |
| 144 | + if (linkExisting && !existingConnectionKey) { | |
| 145 | + toast.error("Select saved credentials"); | |
| 146 | + return; | |
| 147 | + } | |
| 148 | + if (linkExisting && !compatibleConnectionOptions.some((connection) => connection.key === existingConnectionKey)) { | |
| 149 | + toast.error("Selected credentials do not match this scope"); | |
| 150 | + return; | |
| 151 | + } | |
| 152 | + const selectedExistingConnection = linkExisting | |
| 153 | + ? compatibleConnectionOptions.find((connection) => connection.key === existingConnectionKey) ?? null | |
| 154 | + : null; | |
| 155 | + | |
| 156 | + if (selectedAuth.type === "none") { | |
| 157 | + if (authDetectionPending) { | |
| 158 | + toast.error("Still detecting auth from the API spec. Try again in a few seconds."); | |
| 159 | + return; | |
| 160 | + } | |
| 161 | + toast.error("This source is currently set to no auth. Configure auth before adding a connection."); | |
| 162 | + return; | |
| 163 | + } | |
| 164 | + | |
| 165 | + if (selectedAuth.type === "mixed" && !linkExisting && !editing) { | |
| 166 | + toast.error("This API uses mixed auth and must reuse saved credentials"); | |
| 167 | + return; | |
| 168 | + } | |
| 169 | + | |
| 170 | + const secretResult = buildSecretJson({ | |
| 171 | + selectedAuthType: selectedAuth.type, | |
| 172 | + linkExisting, | |
| 173 | + editing: Boolean(editing), | |
| 174 | + basicUsername, | |
| 175 | + basicPassword, | |
| 176 | + apiKeyValue, | |
| 177 | + tokenValue, | |
| 178 | + }); | |
| 179 | + | |
| 180 | + if (!secretResult.secretJson) { | |
| 181 | + toast.error(secretResult.error ?? "Failed to prepare secret payload"); | |
| 182 | + return; | |
| 183 | + } | |
| 184 | + | |
| 185 | + setSaving(true); | |
| 186 | + try { | |
| 187 | + const credentialScopeType = scope === "account" | |
| 188 | + ? "account" | |
| 189 | + : scopeType === "organization" | |
| 190 | + ? "organization" | |
| 191 | + : "workspace"; | |
| 192 | + const additionalHeaders = Object.entries(parsedHeaders.value).map(([name, value]) => ({ | |
| 193 | + name, | |
| 194 | + value, | |
| 195 | + })); | |
| 196 | + | |
| 197 | + await upsertCredential({ | |
| 198 | + ...(editing ? { id: editing.id } : selectedExistingConnection ? { id: selectedExistingConnection.id } : {}), | |
| 199 | + workspaceId: context.workspaceId, | |
| 200 | + sessionId: context.sessionId, | |
| 201 | + sourceKey: sourceKey.trim(), | |
| 202 | + scopeType: credentialScopeType, | |
| 203 | + ...(credentialScopeType === "account" ? { accountId: accountId.trim() as typeof context.accountId } : {}), | |
| 204 | + secretJson: secretResult.secretJson, | |
| 205 | + additionalHeaders, | |
| 206 | + }); | |
| 207 | + | |
| 208 | + toast.success(connectionSuccessCopy(Boolean(editing), linkExisting)); | |
| 209 | + onSaved?.(); | |
| 210 | + onClose(); | |
| 211 | + } catch (err) { | |
| 212 | + toast.error(err instanceof Error ? err.message : "Failed to save connection"); | |
| 213 | + } finally { | |
| 214 | + setSaving(false); | |
| 215 | + } | |
| 216 | + }; | |
| 217 | + | |
| 218 | + const addHeaderRow = () => { | |
| 219 | + setHeaderRows((current) => [...current, { key: "", value: "" }]); | |
| 220 | + }; | |
| 221 | + | |
| 222 | + const removeHeaderRow = (index: number) => { | |
| 223 | + setHeaderRows((current) => { | |
| 224 | + const next = current.filter((_, rowIndex) => rowIndex !== index); | |
| 225 | + return next.length === 0 ? [{ key: "", value: "" }] : next; | |
| 226 | + }); | |
| 227 | + }; | |
| 228 | + | |
| 229 | + const updateHeaderRow = (index: number, patch: Partial<AdditionalHeaderEntry>) => { | |
| 230 | + setHeaderRows((current) => | |
| 231 | + current.map((entry, rowIndex) => { | |
| 232 | + if (rowIndex !== index) { | |
| 233 | + return entry; | |
| 234 | + } | |
| 235 | + return { | |
| 236 | + ...entry, | |
| 237 | + ...patch, | |
| 238 | + }; | |
| 239 | + }), | |
| 240 | + ); | |
| 241 | + }; | |
| 242 | + | |
| 243 | + return ( | |
| 244 | + <div className="flex h-full flex-col"> | |
| 245 | + {/* Panel header */} | |
| 246 | + <div className="shrink-0 flex items-center justify-between gap-3 border-b border-border/30 px-4 py-2.5"> | |
| 247 | + <h3 className="text-sm font-medium"> | |
| 248 | + {editing ? "Update Connection" : "New Connection"} | |
| 249 | + </h3> | |
| 250 | + <Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={onClose}> | |
| 251 | + <X className="h-3.5 w-3.5" /> | |
| 252 | + </Button> | |
| 253 | + </div> | |
| 254 | + | |
| 255 | + {/* Form content */} | |
| 256 | + <div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-5"> | |
| 257 | + <div className="max-w-lg space-y-4"> | |
| 258 | + <div className="space-y-1.5"> | |
| 259 | + <Label className="text-xs text-muted-foreground">API Source</Label> | |
| 260 | + {sourceOptions.length > 0 ? ( | |
| 261 | + <Select value={sourceKey} onValueChange={handleSourceKeyChange}> | |
| 262 | + <SelectTrigger className="h-8 text-xs bg-background"> | |
| 263 | + <SelectValue placeholder="Select a source" /> | |
| 264 | + </SelectTrigger> | |
| 265 | + <SelectContent> | |
| 266 | + {sourceOptions.map((entry) => ( | |
| 267 | + <SelectItem key={entry.key} value={entry.key} className="text-xs"> | |
| 268 | + {entry.label} | |
| 269 | + </SelectItem> | |
| 270 | + ))} | |
| 271 | + </SelectContent> | |
| 272 | + </Select> | |
| 273 | + ) : ( | |
| 274 | + <Input | |
| 275 | + value={sourceKey} | |
| 276 | + onChange={(e) => handleSourceKeyChange(e.target.value)} | |
| 277 | + placeholder="Enter source id" | |
| 278 | + className="h-8 text-xs font-mono bg-background" | |
| 279 | + /> | |
| 280 | + )} | |
| 281 | + </div> | |
| 282 | + | |
| 283 | + <div className="rounded-md border border-border/70 bg-muted/30 px-2.5 py-2"> | |
| 284 | + <div className="flex items-center gap-2 flex-wrap"> | |
| 285 | + <span className="text-[11px] text-muted-foreground">Detected auth</span> | |
| 286 | + <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | |
| 287 | + {detectedAuthLabel} | |
| 288 | + </Badge> | |
| 289 | + {selectedAuth.inferred && !authDetectionPending && ( | |
| 290 | + <Badge variant="outline" className="text-[9px] uppercase tracking-wider"> | |
| 291 | + inferred from spec | |
| 292 | + </Badge> | |
| 293 | + )} | |
| 294 | + </div> | |
| 295 | + {authDetectionPending && ( | |
| 296 | + <p className="text-[10px] text-muted-foreground mt-1"> | |
| 297 | + This spec is still loading. Auth settings will auto-fill once parsing completes. | |
| 298 | + </p> | |
| 299 | + )} | |
| 300 | + {selectedAuth.type === "apiKey" && selectedAuth.header && ( | |
| 301 | + <p className="text-[10px] text-muted-foreground mt-1"> | |
| 302 | + API key header: <span className="font-mono">{selectedAuth.header}</span> | |
| 303 | + </p> | |
| 304 | + )} | |
| 305 | + </div> | |
| 306 | + | |
| 307 | + <div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> | |
| 308 | + <div className="space-y-1.5"> | |
| 309 | + <Label className="text-xs text-muted-foreground">Scope</Label> | |
| 310 | + <Select value={scopePreset} onValueChange={(value) => setScopePreset(value as "only_me" | "workspace" | "organization") }> | |
| 311 | + <SelectTrigger className="h-8 text-xs bg-background"> | |
| 312 | + <SelectValue /> | |
| 313 | + </SelectTrigger> | |
| 314 | + <SelectContent> | |
| 315 | + <SelectItem value="only_me" className="text-xs">Only me</SelectItem> | |
| 316 | + <SelectItem value="workspace" className="text-xs">Workspace</SelectItem> | |
| 317 | + <SelectItem value="organization" className="text-xs">Organization</SelectItem> | |
| 318 | + </SelectContent> | |
| 319 | + </Select> | |
| 320 | + </div> | |
| 321 | + <div className="space-y-1.5"> | |
| 322 | + <Label className="text-xs text-muted-foreground">Storage</Label> | |
| 323 | + <Input value={storageCopy} readOnly className="h-8 text-xs bg-background" /> | |
| 324 | + </div> | |
| 325 | + </div> | |
| 326 | + | |
| 327 | + {scopePreset === "only_me" && ( | |
| 328 | + <div className="space-y-1.5"> | |
| 329 | + <Label className="text-xs text-muted-foreground">Account ID</Label> | |
| 330 | + <Input | |
| 331 | + value={accountId} | |
| 332 | + onChange={(e) => setAccountId(e.target.value)} | |
| 333 | + placeholder="account_123" | |
| 334 | + className="h-8 text-xs font-mono bg-background" | |
| 335 | + /> | |
| 336 | + </div> | |
| 337 | + )} | |
| 338 | + | |
| 339 | + {!editing && connectionOptions.length > 0 && ( | |
| 340 | + <div className="space-y-1.5"> | |
| 341 | + <Label className="text-xs text-muted-foreground">How to connect</Label> | |
| 342 | + <Select value={connectionMode} onValueChange={(value) => setConnectionMode(value as ConnectionMode)}> | |
| 343 | + <SelectTrigger className="h-8 text-xs bg-background"> | |
| 344 | + <SelectValue /> | |
| 345 | + </SelectTrigger> | |
| 346 | + <SelectContent> | |
| 347 | + <SelectItem value="new" className="text-xs">Enter new credentials</SelectItem> | |
| 348 | + <SelectItem value="existing" className="text-xs">Reuse saved credentials</SelectItem> | |
| 349 | + </SelectContent> | |
| 350 | + </Select> | |
| 351 | + </div> | |
| 352 | + )} | |
| 353 | + | |
| 354 | + {!editing && connectionMode === "existing" && ( | |
| 355 | + <div className="space-y-1.5"> | |
| 356 | + <Label className="text-xs text-muted-foreground">Saved Credentials</Label> | |
| 357 | + <Select value={existingConnectionKey} onValueChange={setExistingConnectionKey}> | |
| 358 | + <SelectTrigger className="h-8 text-xs bg-background"> | |
| 359 | + <SelectValue placeholder="Select saved credentials" /> | |
| 360 | + </SelectTrigger> | |
| 361 | + <SelectContent> | |
| 362 | + {compatibleConnectionOptions.map((connection) => ( | |
| 363 | + <SelectItem key={connection.key} value={connection.key} className="text-xs"> | |
| 364 | + {connectionDisplayName(sources, connection)} ({connection.sourceKeys.size} API{connection.sourceKeys.size === 1 ? "" : "s"}) | |
| 365 | + </SelectItem> | |
| 366 | + ))} | |
| 367 | + </SelectContent> | |
| 368 | + </Select> | |
| 369 | + {compatibleConnectionOptions.length === 0 && ( | |
| 370 | + <p className="text-[10px] text-muted-foreground">No compatible saved credentials for this scope.</p> | |
| 371 | + )} | |
| 372 | + </div> | |
| 373 | + )} | |
| 374 | + | |
| 375 | + <ConnectionAuthFields | |
| 376 | + editing={Boolean(editing)} | |
| 377 | + connectionMode={connectionMode} | |
| 378 | + selectedAuth={selectedAuth} | |
| 379 | + tokenValue={tokenValue} | |
| 380 | + apiKeyValue={apiKeyValue} | |
| 381 | + basicUsername={basicUsername} | |
| 382 | + basicPassword={basicPassword} | |
| 383 | + onTokenValueChange={setTokenValue} | |
| 384 | + onApiKeyValueChange={setApiKeyValue} | |
| 385 | + onBasicUsernameChange={setBasicUsername} | |
| 386 | + onBasicPasswordChange={setBasicPassword} | |
| 387 | + /> | |
| 388 | + | |
| 389 | + <div className="space-y-2"> | |
| 390 | + <div className="flex items-center justify-between gap-2"> | |
| 391 | + <Label className="text-xs text-muted-foreground">Extra Headers (optional)</Label> | |
| 392 | + <Button type="button" variant="outline" size="sm" onClick={addHeaderRow} className="h-7 px-2 text-[11px]"> | |
| 393 | + <Plus className="h-3.5 w-3.5 mr-1.5" /> | |
| 394 | + Add header | |
| 395 | + </Button> | |
| 396 | + </div> | |
| 397 | + <div className="space-y-2"> | |
| 398 | + {headerRows.map((headerRow, index) => ( | |
| 399 | + <div key={`${headerRow.key || "header"}-${index}`} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2 items-start"> | |
| 400 | + <Input | |
| 401 | + value={headerRow.key} | |
| 402 | + onChange={(event: ChangeEvent<HTMLInputElement>) => | |
| 403 | + updateHeaderRow(index, { key: event.target.value }) | |
| 404 | + } | |
| 405 | + placeholder="Header" | |
| 406 | + className="h-8 text-xs font-mono bg-background" | |
| 407 | + /> | |
| 408 | + <Input | |
| 409 | + value={headerRow.value} | |
| 410 | + onChange={(event: ChangeEvent<HTMLInputElement>) => | |
| 411 | + updateHeaderRow(index, { value: event.target.value }) | |
| 412 | + } | |
| 413 | + placeholder="Value" | |
| 414 | + className="h-8 text-xs font-mono bg-background" | |
| 415 | + /> | |
| 416 | + <Button | |
| 417 | + type="button" | |
| 418 | + variant="outline" | |
| 419 | + size="sm" | |
| 420 | + className="h-8 px-2" | |
| 421 | + disabled={headerRows.length === 1} | |
| 422 | + onClick={() => void removeHeaderRow(index)} | |
| 423 | + > | |
| 424 | + <Trash2 className="h-3.5 w-3.5" /> | |
| 425 | + </Button> | |
| 426 | + </div> | |
| 427 | + ))} | |
| 428 | + </div> | |
| 429 | + <p className="text-[10px] text-muted-foreground"> | |
| 430 | + Add one `Header` and `Value` pair per line. | |
| 431 | + </p> | |
| 432 | + </div> | |
| 433 | + | |
| 434 | + <Button onClick={handleSave} disabled={saving || authDetectionPending} className="w-full h-9" size="sm"> | |
| 435 | + {connectionSubmitCopy(Boolean(editing), saving, connectionMode)} | |
| 436 | + </Button> | |
| 437 | + </div> | |
| 438 | + </div> | |
| 439 | + </div> | |
| 440 | + ); | |
| 441 | +} | |
| @@ -8,7 +8,6 @@ type BuildSecretJsonParams = { · 7 unmodified lines | ||
| 8 | 8 | basicPassword: string; |
| 9 | 9 | apiKeyValue: string; |
| 10 | 10 | tokenValue: string; |
| 11 | − parsedHeaders: Record<string, string>; | |
| 12 | 11 | }; |
| 13 | 12 | |
| 14 | 13 | export function buildSecretJson({ |
| @@ -19,7 +18,6 @@ export function buildSecretJson({ · 4 unmodified lines | ||
| 19 | 18 | basicPassword, |
| 20 | 19 | apiKeyValue, |
| 21 | 20 | tokenValue, |
| 22 | − parsedHeaders, | |
| 23 | 21 | }: BuildSecretJsonParams): { secretJson?: Record<string, unknown>; error?: string } { |
| 24 | 22 | const secretJson: Record<string, unknown> = {}; |
| 25 | 23 | if (!linkExisting) { |
| @@ -43,11 +41,6 @@ export function buildSecretJson({ · 17 unmodified lines | ||
| 43 | 41 | } |
| 44 | 42 | } |
| 45 | 43 | } |
| 46 | − | |
| 47 | − if (Object.keys(parsedHeaders).length > 0) { | |
| 48 | − secretJson.__headers = parsedHeaders; | |
| 49 | − } | |
| 50 | − | |
| 51 | 44 | if (Object.keys(secretJson).length === 0 && !editing && !linkExisting) { |
| 52 | 45 | if (selectedAuthType === "basic") { |
| 53 | 46 | return { error: "Username and password are required" }; |
| @@ -6,7 +6,7 @@ import type { · 5 unmodified lines | ||
| 6 | 6 | ToolSourceRecord, |
| 7 | 7 | } from "@/lib/types"; |
| 8 | 8 | import { |
| 9 | − formatHeaderOverrides, | |
| 9 | + formatAdditionalHeaders, | |
| 10 | 10 | sourceAuthForKey, |
| 11 | 11 | } from "@/lib/credentials/source-helpers"; |
| 12 | 12 | import { |
| @@ -40,7 +40,7 @@ type FormState = { · 27 unmodified lines | ||
| 40 | 40 | apiKeyValue: string; |
| 41 | 41 | basicUsername: string; |
| 42 | 42 | basicPassword: string; |
| 43 | − customHeadersText: string; | |
| 43 | + additionalHeadersText: string; | |
| 44 | 44 | }; |
| 45 | 45 | |
| 46 | 46 | type SharingScope = "only_me" | "workspace" | "organization"; |
| @@ -102,7 +102,7 @@ function initialFormState({ · 55 unmodified lines | ||
| 102 | 102 | apiKeyValue: "", |
| 103 | 103 | basicUsername: "", |
| 104 | 104 | basicPassword: "", |
| 105 | − customHeadersText: formatHeaderOverrides(editing.overridesJson), | |
| 105 | + additionalHeadersText: formatAdditionalHeaders(editing.additionalHeaders), | |
| 106 | 106 | }; |
| 107 | 107 | } |
| 108 | 108 | |
| @@ -120,7 +120,7 @@ function initialFormState({ · 11 unmodified lines | ||
| 120 | 120 | apiKeyValue: "", |
| 121 | 121 | basicUsername: "", |
| 122 | 122 | basicPassword: "", |
| 123 | − customHeadersText: "", | |
| 123 | + additionalHeadersText: "", | |
| 124 | 124 | }; |
| 125 | 125 | } |
| 126 | 126 | |
| @@ -157,7 +157,7 @@ export function useConnectionFormDialogForm({ · 30 unmodified lines | ||
| 157 | 157 | apiKeyValue, |
| 158 | 158 | basicUsername, |
| 159 | 159 | basicPassword, |
| 160 | − customHeadersText, | |
| 160 | + additionalHeadersText, | |
| 161 | 161 | } = form; |
| 162 | 162 | const compatibleConnectionOptions = useMemo( |
| 163 | 163 | () => compatibleConnections(connectionOptions, scopeType, scope, accountId), |
| @@ -253,8 +253,8 @@ export function useConnectionFormDialogForm({ · 89 unmodified lines | ||
| 253 | 253 | dispatch({ type: "patch", patch: { basicPassword: nextBasicPassword } }); |
| 254 | 254 | }; |
| 255 | 255 | |
| 256 | − const setCustomHeadersText = (nextCustomHeadersText: string) => { | |
| 257 | − dispatch({ type: "patch", patch: { customHeadersText: nextCustomHeadersText } }); | |
| 256 | + const setAdditionalHeadersText = (nextAdditionalHeadersText: string) => { | |
| 257 | + dispatch({ type: "patch", patch: { additionalHeadersText: nextAdditionalHeadersText } }); | |
| 258 | 258 | }; |
| 259 | 259 | |
| 260 | 260 | return { |
| @@ -269,7 +269,7 @@ export function useConnectionFormDialogForm({ · 8 unmodified lines | ||
| 269 | 269 | apiKeyValue, |
| 270 | 270 | basicUsername, |
| 271 | 271 | basicPassword, |
| 272 | − customHeadersText, | |
| 272 | + additionalHeadersText, | |
| 273 | 273 | sourceOptions, |
| 274 | 274 | connectionOptions, |
| 275 | 275 | compatibleConnectionOptions, |
| @@ -283,7 +283,7 @@ export function useConnectionFormDialogForm({ · 7 unmodified lines | ||
| 283 | 283 | setApiKeyValue, |
| 284 | 284 | setBasicUsername, |
| 285 | 285 | setBasicPassword, |
| 286 | − setCustomHeadersText, | |
| 286 | + setAdditionalHeadersText, | |
| 287 | 287 | handleSourceKeyChange, |
| 288 | 288 | }; |
| 289 | 289 | } |
| @@ -1,46 +1,47 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useMemo } from "react"; | |
| 3 | +import { useMemo, useState } from "react"; | |
| 4 | 4 | import { KeyRound, Plus } from "lucide-react"; |
| 5 | 5 | import { Button } from "@/components/ui/button"; |
| 6 | 6 | import { Badge } from "@/components/ui/badge"; |
| 7 | 7 | import { Skeleton } from "@/components/ui/skeleton"; |
| 8 | −import { useSession } from "@/lib/session-context"; | |
| 9 | 8 | import type { |
| 10 | 9 | CredentialRecord, |
| 10 | + SourceAuthProfile, | |
| 11 | 11 | ToolSourceScopeType, |
| 12 | 12 | ToolSourceRecord, |
| 13 | 13 | } from "@/lib/types"; |
| 14 | 14 | import { |
| 15 | 15 | connectionDisplayName, |
| 16 | − ownerScopeLabel, | |
| 17 | − providerLabel, | |
| 18 | 16 | } from "@/lib/credentials/source-helpers"; |
| 19 | 17 | import { |
| 20 | 18 | sourceForCredentialKey, |
| 21 | 19 | } from "@/lib/tools/source-helpers"; |
| 22 | 20 | import { SourceFavicon } from "./source-favicon"; |
| 21 | +import { ConnectionFormPanel } from "./connection/form-panel"; | |
| 22 | +import { cn } from "@/lib/utils"; | |
| 23 | 23 | |
| 24 | 24 | type ConnectionScope = "account" | "workspace"; |
| 25 | 25 | |
| 26 | +type PanelState = | |
| 27 | + | { mode: "idle" } | |
| 28 | + | { mode: "create"; sourceKey?: string } | |
| 29 | + | { mode: "edit"; credential: CredentialRecord }; | |
| 30 | + | |
| 26 | 31 | export function CredentialsPanel({ |
| 27 | 32 | sources, |
| 28 | 33 | credentials, |
| 29 | 34 | loading, |
| 30 | − onCreateConnection, | |
| 31 | − onEditConnection, | |
| 35 | + sourceAuthProfiles, | |
| 36 | + loadingSourceNames, | |
| 32 | 37 | }: { |
| 33 | 38 | sources: ToolSourceRecord[]; |
| 34 | 39 | credentials: CredentialRecord[]; |
| 35 | 40 | loading: boolean; |
| 36 | − onCreateConnection: (sourceKey?: string) => void; | |
| 37 | − onEditConnection: (credential: CredentialRecord) => void; | |
| 41 | + sourceAuthProfiles: Record<string, SourceAuthProfile>; | |
| 42 | + loadingSourceNames?: string[]; | |
| 38 | 43 | }) { |
| 39 | − const { clientConfig } = useSession(); | |
| 40 | − | |
| 41 | − const storageCopy = clientConfig?.authProviderMode === "workos" | |
| 42 | − ? "Stored encrypted" | |
| 43 | − : "Stored locally on this machine"; | |
| 44 | + const [panel, setPanel] = useState<PanelState>({ mode: "idle" }); | |
| 44 | 45 | |
| 45 | 46 | const connectionOptions = useMemo(() => { |
| 46 | 47 | const grouped = new Map<string, { |
| @@ -91,104 +92,184 @@ export function CredentialsPanel({ · 44 unmodified lines | ||
| 91 | 92 | return map; |
| 92 | 93 | }, [credentials]); |
| 93 | 94 | |
| 95 | + const selectedConnectionKey = panel.mode === "edit" | |
| 96 | + ? (() => { | |
| 97 | + const scopeType = panel.credential.scopeType === "organization" ? "organization" : "workspace"; | |
| 98 | + return `${scopeType}:${panel.credential.id}`; | |
| 99 | + })() | |
| 100 | + : null; | |
| 101 | + | |
| 94 | 102 | return ( |
| 95 | − <section className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col rounded-none border border-border/50 bg-card/30"> | |
| 96 | − <div className="flex items-center justify-between border-b border-border/40 px-4 py-3 sm:px-5"> | |
| 97 | − <div className="flex items-center gap-2"> | |
| 98 | − <div className="flex h-7 w-7 items-center justify-center border border-border/60 bg-background/80"> | |
| 99 | − <KeyRound className="h-4 w-4 text-muted-foreground" /> | |
| 103 | + <section className="flex h-full min-h-0 w-full overflow-hidden bg-background"> | |
| 104 | + {/* ── Left sidebar ── */} | |
| 105 | + <aside className="flex h-full w-64 shrink-0 flex-col border-r border-border/40 bg-card/30 lg:w-72"> | |
| 106 | + {/* Sidebar header */} | |
| 107 | + <div className="shrink-0 border-b border-border/30 px-3 py-2.5"> | |
| 108 | + <div className="flex items-center justify-between"> | |
| 109 | + <div className="flex items-center gap-2"> | |
| 110 | + <KeyRound className="h-4 w-4 text-muted-foreground" /> | |
| 111 | + <h3 className="text-sm font-medium leading-none">Connections</h3> | |
| 112 | + </div> | |
| 113 | + <Button | |
| 114 | + size="sm" | |
| 115 | + variant="ghost" | |
| 116 | + className="h-7 w-7 p-0" | |
| 117 | + onClick={() => setPanel({ mode: "create" })} | |
| 118 | + > | |
| 119 | + <Plus className="h-3.5 w-3.5" /> | |
| 120 | + </Button> | |
| 100 | 121 | </div> |
| 101 | − <h2 className="text-sm font-medium">Connections</h2> | |
| 102 | − </div> | |
| 103 | − <div className="flex items-center gap-2"> | |
| 104 | − <Badge variant="outline" className="h-5 px-2 text-[10px] font-mono uppercase tracking-wide text-muted-foreground"> | |
| 105 | − {connectionOptions.length} | |
| 106 | − </Badge> | |
| 107 | − <Button size="sm" className="h-8 text-xs" onClick={() => onCreateConnection()}> | |
| 108 | − <Plus className="h-3.5 w-3.5 mr-1.5" /> | |
| 109 | − Add Connection | |
| 110 | − </Button> | |
| 122 | + <p className="text-[10px] text-muted-foreground mt-1"> | |
| 123 | + {connectionOptions.length} {connectionOptions.length === 1 ? "connection" : "connections"} | |
| 124 | + </p> | |
| 111 | 125 | </div> |
| 112 | − </div> | |
| 113 | 126 | |
| 114 | − <div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5"> | |
| 115 | − {loading ? ( | |
| 116 | − <div className="space-y-2"> | |
| 117 | − {Array.from({ length: 3 }).map((_, i) => ( | |
| 118 | − <Skeleton key={i} className="h-14" /> | |
| 119 | − ))} | |
| 120 | − </div> | |
| 121 | − ) : connectionOptions.length === 0 ? ( | |
| 122 | − <div className="flex h-full min-h-[280px] flex-col items-center justify-center gap-2 border border-dashed border-border/50 bg-background/50"> | |
| 123 | − <div className="flex h-10 w-10 items-center justify-center border border-border/60 bg-muted/40"> | |
| 124 | − <KeyRound className="h-5 w-5 text-muted-foreground/50" /> | |
| 127 | + {/* Sidebar connection list */} | |
| 128 | + <div className="flex-1 min-h-0 overflow-y-auto"> | |
| 129 | + {loading ? ( | |
| 130 | + <div className="space-y-2 p-3"> | |
| 131 | + {Array.from({ length: 4 }).map((_, i) => ( | |
| 132 | + <Skeleton key={i} className="h-12 rounded-md" /> | |
| 133 | + ))} | |
| 125 | 134 | </div> |
| 126 | − <p className="text-sm text-muted-foreground">No connections configured</p> | |
| 127 | − <p className="text-[11px] text-muted-foreground/70 text-center max-w-md"> | |
| 128 | − Add a source, then create or link a reusable connection. | |
| 129 | − </p> | |
| 130 | − </div> | |
| 131 | − ) : ( | |
| 132 | − <div className="space-y-2"> | |
| 133 | − {connectionOptions.map((connection) => { | |
| 134 | − const representative = representativeCredentialByConnection.get(connection.key); | |
| 135 | − if (!representative) { | |
| 136 | − return null; | |
| 137 | − } | |
| 138 | − const firstSource = sourceForCredentialKey(sources, representative.sourceKey); | |
| 139 | − return ( | |
| 140 | − <div | |
| 141 | − key={connection.key} | |
| 142 | − className="group flex items-center gap-3 border border-border/50 bg-background/70 px-3 py-2.5 transition-colors hover:border-border hover:bg-accent/20" | |
| 143 | − style={{ boxShadow: "inset 2px 0 0 0 color-mix(in oklch, var(--border) 70%, transparent)" }} | |
| 144 | − > | |
| 145 | − <div className="flex h-8 w-8 shrink-0 items-center justify-center border border-border/60 bg-muted/50 overflow-hidden"> | |
| 146 | − {firstSource ? ( | |
| 147 | − <SourceFavicon | |
| 148 | − source={firstSource} | |
| 149 | − iconClassName="h-4 w-4 text-muted-foreground" | |
| 150 | − imageClassName="w-5 h-5" | |
| 151 | − /> | |
| 152 | − ) : ( | |
| 153 | − <KeyRound className="h-4 w-4 text-muted-foreground" /> | |
| 135 | + ) : connectionOptions.length === 0 ? ( | |
| 136 | + <div className="flex flex-col items-center gap-2.5 px-4 py-10"> | |
| 137 | + <div className="flex h-10 w-10 items-center justify-center border border-border/60 bg-muted/40"> | |
| 138 | + <KeyRound className="h-5 w-5 text-muted-foreground/50" /> | |
| 139 | + </div> | |
| 140 | + <div className="text-center"> | |
| 141 | + <p className="text-xs text-muted-foreground">No connections</p> | |
| 142 | + <p className="text-[10px] text-muted-foreground/60 mt-0.5"> | |
| 143 | + Add a source, then create or link a reusable connection. | |
| 144 | + </p> | |
| 145 | + </div> | |
| 146 | + <Button | |
| 147 | + variant="outline" | |
| 148 | + size="sm" | |
| 149 | + className="h-7 text-[11px] gap-1 mt-1" | |
| 150 | + onClick={() => setPanel({ mode: "create" })} | |
| 151 | + > | |
| 152 | + <Plus className="h-3 w-3" /> | |
| 153 | + Add connection | |
| 154 | + </Button> | |
| 155 | + </div> | |
| 156 | + ) : ( | |
| 157 | + <div className="p-2 space-y-1"> | |
| 158 | + {connectionOptions.map((connection) => { | |
| 159 | + const representative = representativeCredentialByConnection.get(connection.key); | |
| 160 | + if (!representative) { | |
| 161 | + return null; | |
| 162 | + } | |
| 163 | + const firstSource = sourceForCredentialKey(sources, representative.sourceKey); | |
| 164 | + const isSelected = selectedConnectionKey === connection.key; | |
| 165 | + return ( | |
| 166 | + <button | |
| 167 | + key={connection.key} | |
| 168 | + type="button" | |
| 169 | + onClick={() => setPanel({ mode: "edit", credential: representative })} | |
| 170 | + className={cn( | |
| 171 | + "group flex w-full items-center gap-2.5 rounded-md border px-2.5 py-2 text-left transition-colors", | |
| 172 | + isSelected | |
| 173 | + ? "border-primary/40 bg-primary/5" | |
| 174 | + : "border-border/40 bg-background/70 hover:border-border hover:bg-accent/20", | |
| 154 | 175 | )} |
| 155 | − </div> | |
| 156 | − <div className="flex-1 min-w-0"> | |
| 157 | − <div className="mb-1 flex items-center gap-1.5 flex-wrap"> | |
| 158 | − <span className="text-sm font-medium">{connectionDisplayName(sources, connection)}</span> | |
| 159 | − <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | |
| 160 | − {connection.scope} | |
| 161 | − </Badge> | |
| 162 | − <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | |
| 163 | − {ownerScopeLabel(connection.scopeType)} | |
| 164 | − </Badge> | |
| 165 | − <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | |
| 166 | − {providerLabel(connection.provider)} | |
| 167 | − </Badge> | |
| 168 | − {connection.scope === "account" && connection.accountId ? ( | |
| 169 | − <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded-sm"> | |
| 170 | − {connection.accountId} | |
| 176 | + > | |
| 177 | + <div className="flex h-7 w-7 shrink-0 items-center justify-center border border-border/60 bg-muted/50 overflow-hidden rounded-sm"> | |
| 178 | + {firstSource ? ( | |
| 179 | + <SourceFavicon | |
| 180 | + source={firstSource} | |
| 181 | + iconClassName="h-3.5 w-3.5 text-muted-foreground" | |
| 182 | + imageClassName="w-4 h-4" | |
| 183 | + /> | |
| 184 | + ) : ( | |
| 185 | + <KeyRound className="h-3.5 w-3.5 text-muted-foreground" /> | |
| 186 | + )} | |
| 187 | + </div> | |
| 188 | + <div className="flex-1 min-w-0"> | |
| 189 | + <span className="text-xs font-medium truncate block">{connectionDisplayName(sources, connection)}</span> | |
| 190 | + <div className="flex items-center gap-1 mt-0.5"> | |
| 191 | + <Badge variant="outline" className="text-[8px] px-1 py-0 font-mono uppercase tracking-wider"> | |
| 192 | + {connection.scope} | |
| 193 | + </Badge> | |
| 194 | + <span className="text-[9px] text-muted-foreground"> | |
| 195 | + {connection.sourceKeys.size} API{connection.sourceKeys.size === 1 ? "" : "s"} | |
| 171 | 196 | </span> |
| 172 | − ) : null} | |
| 197 | + </div> | |
| 173 | 198 | </div> |
| 174 | − <p className="text-[11px] text-muted-foreground"> | |
| 175 | − Linked to {connection.sourceKeys.size} API{connection.sourceKeys.size === 1 ? "" : "s"} - {storageCopy} | |
| 176 | − </p> | |
| 177 | − <p className="mt-0.5 text-[11px] text-muted-foreground"> | |
| 178 | − Updated {new Date(connection.updatedAt).toLocaleString()} | |
| 179 | − </p> | |
| 180 | − </div> | |
| 199 | + </button> | |
| 200 | + ); | |
| 201 | + })} | |
| 202 | + </div> | |
| 203 | + )} | |
| 204 | + </div> | |
| 205 | + </aside> | |
| 206 | + | |
| 207 | + {/* ── Right content panel ── */} | |
| 208 | + <div className="flex-1 min-w-0 max-h-screen overflow-hidden bg-background/50"> | |
| 209 | + {panel.mode === "create" ? ( | |
| 210 | + <ConnectionFormPanel | |
| 211 | + key="create" | |
| 212 | + editing={null} | |
| 213 | + initialSourceKey={panel.sourceKey} | |
| 214 | + sources={sources} | |
| 215 | + credentials={credentials} | |
| 216 | + sourceAuthProfiles={sourceAuthProfiles} | |
| 217 | + loadingSourceNames={loadingSourceNames} | |
| 218 | + onClose={() => setPanel({ mode: "idle" })} | |
| 219 | + /> | |
| 220 | + ) : panel.mode === "edit" ? ( | |
| 221 | + <ConnectionFormPanel | |
| 222 | + key={`edit-${panel.credential.id}`} | |
| 223 | + editing={panel.credential} | |
| 224 | + sources={sources} | |
| 225 | + credentials={credentials} | |
| 226 | + sourceAuthProfiles={sourceAuthProfiles} | |
| 227 | + loadingSourceNames={loadingSourceNames} | |
| 228 | + onClose={() => setPanel({ mode: "idle" })} | |
| 229 | + /> | |
| 230 | + ) : ( | |
| 231 | + <div className="flex h-full items-center justify-center"> | |
| 232 | + <div className="text-center space-y-3 max-w-sm px-6"> | |
| 233 | + <div className="flex h-12 w-12 mx-auto items-center justify-center border border-border/60 bg-muted/40 rounded-sm"> | |
| 234 | + <KeyRound className="h-6 w-6 text-muted-foreground/40" /> | |
| 235 | + </div> | |
| 236 | + {loading ? ( | |
| 237 | + <p className="text-xs text-muted-foreground">Loading connections...</p> | |
| 238 | + ) : connectionOptions.length === 0 ? ( | |
| 239 | + <> | |
| 240 | + <p className="text-sm text-muted-foreground">No connections configured</p> | |
| 241 | + <p className="text-[11px] text-muted-foreground/60"> | |
| 242 | + Add a source, then create or link a reusable connection to get started. | |
| 243 | + </p> | |
| 181 | 244 | <Button |
| 182 | − variant="outline" | |
| 183 | 245 | size="sm" |
| 184 | − className="h-7 text-[11px] border-border/70 group-hover:border-border" | |
| 185 | − onClick={() => onEditConnection(representative)} | |
| 246 | + className="h-8 text-xs gap-1.5 mt-2" | |
| 247 | + onClick={() => setPanel({ mode: "create" })} | |
| 186 | 248 | > |
| 187 | − Edit | |
| 249 | + <Plus className="h-3.5 w-3.5" /> | |
| 250 | + Add Connection | |
| 188 | 251 | </Button> |
| 189 | − </div> | |
| 190 | − ); | |
| 191 | − })} | |
| 252 | + </> | |
| 253 | + ) : ( | |
| 254 | + <> | |
| 255 | + <p className="text-sm text-muted-foreground"> | |
| 256 | + {connectionOptions.length} {connectionOptions.length === 1 ? "connection" : "connections"} configured | |
| 257 | + </p> | |
| 258 | + <p className="text-[11px] text-muted-foreground/60"> | |
| 259 | + Select a connection from the sidebar to edit it, or add a new one. | |
| 260 | + </p> | |
| 261 | + <Button | |
| 262 | + size="sm" | |
| 263 | + variant="outline" | |
| 264 | + className="h-8 text-xs gap-1.5 mt-2" | |
| 265 | + onClick={() => setPanel({ mode: "create" })} | |
| 266 | + > | |
| 267 | + <Plus className="h-3.5 w-3.5" /> | |
| 268 | + Add Connection | |
| 269 | + </Button> | |
| 270 | + </> | |
| 271 | + )} | |
| 272 | + </div> | |
| 192 | 273 | </div> |
| 193 | 274 | )} |
| 194 | 275 | </div> |
| @@ -252,12 +252,3 @@ export function autoExpandedKeysForSearch( · 251 unmodified lines | ||
| 252 | 252 | |
| 253 | 253 | return allGroupKeys; |
| 254 | 254 | } |
| 255 | − | |
| 256 | −export function countSelectedTools( | |
| 257 | − selectedKeys: Set<string>, | |
| 258 | − filteredTools: ToolDescriptor[], | |
| 259 | −): number { | |
| 260 | − return Array.from(selectedKeys).filter((key) => | |
| 261 | − filteredTools.some((tool) => tool.path === key), | |
| 262 | − ).length; | |
| 263 | −} | |
| @@ -130,7 +130,7 @@ export function GroupNode({ · 129 unmodified lines | ||
| 130 | 130 | |
| 131 | 131 | <span |
| 132 | 132 | className={cn( |
| 133 | − "font-mono text-[13px] truncate", | |
| 133 | + "truncate text-sm", | |
| 134 | 134 | isSource |
| 135 | 135 | ? "font-semibold text-foreground" |
| 136 | 136 | : "font-medium text-foreground/90", |
| @@ -139,9 +139,9 @@ export function GroupNode({ · 2 unmodified lines | ||
| 139 | 139 | {displayLabel} |
| 140 | 140 | </span> |
| 141 | 141 | |
| 142 | − <span className="text-[10px] font-mono text-muted-foreground/60 ml-auto flex items-center gap-2 shrink-0"> | |
| 142 | + <span className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground/70"> | |
| 143 | 143 | {isSource && group.sourceType && ( |
| 144 | − <span className="uppercase tracking-wider opacity-70"> | |
| 144 | + <span className="opacity-80"> | |
| 145 | 145 | {group.sourceType} |
| 146 | 146 | </span> |
| 147 | 147 | )} |
| @@ -226,9 +226,7 @@ export function NavGroupNode({ · 78 unmodified lines | ||
| 226 | 226 | onToggle, |
| 227 | 227 | focusedPath, |
| 228 | 228 | focusedSource, |
| 229 | − selectedKeys, | |
| 230 | 229 | onFocusTool, |
| 231 | − onSelectTool, | |
| 232 | 230 | onSourceClick, |
| 233 | 231 | source, |
| 234 | 232 | }: { |
| @@ -238,9 +236,7 @@ export function NavGroupNode({ · 3 unmodified lines | ||
| 238 | 236 | onToggle: (key: string) => void; |
| 239 | 237 | focusedPath: string | null; |
| 240 | 238 | focusedSource?: string | null; |
| 241 | − selectedKeys: Set<string>; | |
| 242 | 239 | onFocusTool: (tool: ToolDescriptor) => void; |
| 243 | − onSelectTool?: (path: string, e: React.MouseEvent) => void; | |
| 244 | 240 | onSourceClick?: (sourceName: string) => void; |
| 245 | 241 | source?: ToolSourceRecord; |
| 246 | 242 | }) { |
| @@ -316,11 +312,11 @@ export function NavGroupNode({ · 69 unmodified lines | ||
| 316 | 312 | )} |
| 317 | 313 | </div> |
| 318 | 314 | |
| 319 | − <span className="font-mono text-[11px] font-semibold text-foreground/90 truncate"> | |
| 315 | + <span className="truncate text-xs font-semibold text-foreground/90"> | |
| 320 | 316 | {displayLabel} |
| 321 | 317 | </span> |
| 322 | 318 | |
| 323 | − <span className="text-[9px] font-mono text-muted-foreground/50 ml-auto flex items-center gap-1 shrink-0"> | |
| 319 | + <span className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground/60"> | |
| 324 | 320 | {isLoading ? ( |
| 325 | 321 | <Loader2 className="h-2.5 w-2.5 animate-spin" /> |
| 326 | 322 | ) : ( |
| @@ -360,11 +356,11 @@ export function NavGroupNode({ · 33 unmodified lines | ||
| 360 | 356 | )} |
| 361 | 357 | </div> |
| 362 | 358 | |
| 363 | − <span className="font-mono text-[11px] font-medium text-foreground/80 truncate"> | |
| 359 | + <span className="truncate text-xs font-medium text-foreground/80"> | |
| 364 | 360 | {displayLabel} |
| 365 | 361 | </span> |
| 366 | 362 | |
| 367 | − <span className="text-[9px] font-mono text-muted-foreground/50 ml-auto flex items-center gap-1 shrink-0"> | |
| 363 | + <span className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground/60"> | |
| 368 | 364 | {group.approvalCount > 0 && ( |
| 369 | 365 | <span className="inline-flex items-center gap-0.5 text-terminal-amber"> |
| 370 | 366 | <ShieldCheck className="h-2 w-2" /> |
| @@ -390,9 +386,7 @@ export function NavGroupNode({ · 19 unmodified lines | ||
| 390 | 386 | onToggle={onToggle} |
| 391 | 387 | focusedPath={focusedPath} |
| 392 | 388 | focusedSource={focusedSource} |
| 393 | − selectedKeys={selectedKeys} | |
| 394 | 389 | onFocusTool={onFocusTool} |
| 395 | − onSelectTool={onSelectTool} | |
| 396 | 390 | onSourceClick={onSourceClick} |
| 397 | 391 | /> |
| 398 | 392 | ); |
| @@ -404,13 +398,7 @@ export function NavGroupNode({ · 5 unmodified lines | ||
| 404 | 398 | tool={child} |
| 405 | 399 | label={toolDisplayOperation(child.path)} |
| 406 | 400 | focused={child.path === focusedPath} |
| 407 | − selected={selectedKeys.has(child.path)} | |
| 408 | 401 | onFocus={onFocusTool} |
| 409 | − onSelect={ | |
| 410 | − onSelectTool | |
| 411 | − ? (e) => onSelectTool(child.path, e) | |
| 412 | − : undefined | |
| 413 | − } | |
| 414 | 402 | depth={depth + 1} |
| 415 | 403 | /> |
| 416 | 404 | ); |
| @@ -568,7 +556,7 @@ export function SourceSidebar({ · 151 unmodified lines | ||
| 568 | 556 | <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block"> |
| 569 | 557 | <div className="px-3 pb-2 pt-1"> |
| 570 | 558 | <div className="flex items-center justify-between"> |
| 571 | − <p className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground/50"> | |
| 559 | + <p className="text-xs font-medium text-muted-foreground/70"> | |
| 572 | 560 | Sources |
| 573 | 561 | </p> |
| 574 | 562 | {onRegenerate ? ( |
| @@ -597,7 +585,7 @@ export function SourceSidebar({ · 22 unmodified lines | ||
| 597 | 585 | </div> |
| 598 | 586 | <p |
| 599 | 587 | className={cn( |
| 600 | − "mt-1 flex items-center gap-1 text-[10px] font-mono", | |
| 588 | + "mt-1 flex items-center gap-1 text-xs", | |
| 601 | 589 | inventoryStatus.tone === "loading" |
| 602 | 590 | ? "text-terminal-amber/90" |
| 603 | 591 | : inventoryStatus.tone === "error" |
| @@ -644,8 +632,8 @@ export function SourceSidebar({ · 40 unmodified lines | ||
| 644 | 632 | className="h-3 w-3 shrink-0 text-muted-foreground" |
| 645 | 633 | /> |
| 646 | 634 | )} |
| 647 | − <span className="font-mono font-medium truncate">{g.name}</span> | |
| 648 | − <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60 flex items-center gap-1"> | |
| 635 | + <span className="truncate font-medium">{g.name}</span> | |
| 636 | + <span className="ml-auto flex items-center gap-1 text-xs tabular-nums opacity-70"> | |
| 649 | 637 | {g.warningCount > 0 ? ( |
| 650 | 638 | <span |
| 651 | 639 | className="inline-flex items-center gap-0.5 text-terminal-amber" |
| @@ -690,12 +678,12 @@ export function SourceSidebar({ · 38 unmodified lines | ||
| 690 | 678 | |
| 691 | 679 | {activeSource && activeSourceWarnings.length > 0 ? ( |
| 692 | 680 | <div className="mt-2 rounded-md border border-terminal-amber/30 bg-terminal-amber/5 px-2 py-2"> |
| 693 | − <p className="text-[10px] font-mono text-terminal-amber/90"> | |
| 681 | + <p className="text-xs font-medium text-terminal-amber/90"> | |
| 694 | 682 | {activeSourceWarnings.length} warning{activeSourceWarnings.length !== 1 ? "s" : ""} |
| 695 | 683 | </p> |
| 696 | 684 | <div className="mt-1.5 space-y-1"> |
| 697 | 685 | {activeSourceWarnings.map((warning, index) => ( |
| 698 | − <p key={`${activeSource}-${index}`} className="text-[10px] leading-4 text-muted-foreground"> | |
| 686 | + <p key={`${activeSource}-${index}`} className="text-xs leading-4 text-muted-foreground"> | |
| 699 | 687 | {warning} |
| 700 | 688 | </p> |
| 701 | 689 | ))} |
| @@ -25,17 +25,13 @@ export const ToolListItem = memo(function ToolListItem({ · 24 unmodified lines | ||
| 25 | 25 | tool, |
| 26 | 26 | label, |
| 27 | 27 | focused, |
| 28 | − selected, | |
| 29 | 28 | onFocus, |
| 30 | − onSelect, | |
| 31 | 29 | depth = 0, |
| 32 | 30 | }: { |
| 33 | 31 | tool: ToolDescriptor; |
| 34 | 32 | label: string; |
| 35 | 33 | focused: boolean; |
| 36 | − selected: boolean; | |
| 37 | 34 | onFocus: (tool: ToolDescriptor) => void; |
| 38 | − onSelect?: (e: React.MouseEvent) => void; | |
| 39 | 35 | depth?: number; |
| 40 | 36 | }) { |
| 41 | 37 | const handleClick = useCallback(() => { |
| @@ -46,31 +42,13 @@ export const ToolListItem = memo(function ToolListItem({ · 4 unmodified lines | ||
| 46 | 42 | <div |
| 47 | 43 | onClick={handleClick} |
| 48 | 44 | className={cn( |
| 49 | − "flex items-center gap-1.5 px-2 py-1 cursor-pointer transition-colors group/item", | |
| 45 | + "flex items-center gap-1.5 px-2 py-1 cursor-pointer transition-colors", | |
| 50 | 46 | focused |
| 51 | 47 | ? "bg-accent/50 border-l-2 border-l-primary" |
| 52 | 48 | : "hover:bg-accent/20 border-l-2 border-l-transparent", |
| 53 | − selected && !focused && "bg-primary/5", | |
| 54 | 49 | )} |
| 55 | 50 | style={{ paddingLeft: `${depth * 14 + 8}px` }} |
| 56 | 51 | > |
| 57 | − {onSelect ? ( | |
| 58 | − <button | |
| 59 | − onClick={(e) => { | |
| 60 | − e.stopPropagation(); | |
| 61 | − onSelect(e); | |
| 62 | − }} | |
| 63 | − className={cn( | |
| 64 | − "h-3.5 w-3.5 rounded border flex items-center justify-center shrink-0 transition-colors", | |
| 65 | − selected | |
| 66 | − ? "bg-primary border-primary text-primary-foreground" | |
| 67 | − : "border-border/60 opacity-0 group-hover/item:opacity-100", | |
| 68 | − )} | |
| 69 | − > | |
| 70 | − {selected && <Check className="h-2 w-2" />} | |
| 71 | − </button> | |
| 72 | − ) : null} | |
| 73 | − | |
| 74 | 52 | <Zap className="h-2.5 w-2.5 text-primary/50 shrink-0" /> |
| 75 | 53 | |
| 76 | 54 | <span |
| @@ -92,7 +70,6 @@ export const ToolListItem = memo(function ToolListItem({ · 15 unmodified lines | ||
| 92 | 70 | prev.tool === next.tool && |
| 93 | 71 | prev.label === next.label && |
| 94 | 72 | prev.focused === next.focused && |
| 95 | − prev.selected === next.selected && | |
| 96 | 73 | prev.depth === next.depth, |
| 97 | 74 | ); |
| 98 | 75 | |
| @@ -235,9 +212,7 @@ export const SelectableToolRow = memo(function SelectableToolRow({ · 136 unmodified lines | ||
| 235 | 212 | export function ToolListSidebar({ |
| 236 | 213 | tools, |
| 237 | 214 | focusedPath, |
| 238 | − selectedKeys, | |
| 239 | 215 | onFocusTool, |
| 240 | − onSelectTool, | |
| 241 | 216 | loadingRows, |
| 242 | 217 | hasMoreTools = false, |
| 243 | 218 | loadingMoreTools = false, |
| @@ -247,9 +222,7 @@ export function ToolListSidebar({ · 3 unmodified lines | ||
| 247 | 222 | }: { |
| 248 | 223 | tools: ToolDescriptor[]; |
| 249 | 224 | focusedPath: string | null; |
| 250 | − selectedKeys: Set<string>; | |
| 251 | 225 | onFocusTool: (tool: ToolDescriptor) => void; |
| 252 | − onSelectTool?: (path: string, e: React.MouseEvent) => void; | |
| 253 | 226 | loadingRows?: { source: string; count: number }[]; |
| 254 | 227 | hasMoreTools?: boolean; |
| 255 | 228 | loadingMoreTools?: boolean; |
| @@ -284,13 +257,7 @@ export function ToolListSidebar({ · 28 unmodified lines | ||
| 284 | 257 | tool={tool} |
| 285 | 258 | label={toolDisplayPath(tool.path)} |
| 286 | 259 | focused={tool.path === focusedPath} |
| 287 | − selected={selectedKeys.has(tool.path)} | |
| 288 | 260 | onFocus={onFocusTool} |
| 289 | − onSelect={ | |
| 290 | − onSelectTool | |
| 291 | − ? (e) => onSelectTool(tool.path, e) | |
| 292 | − : undefined | |
| 293 | − } | |
| 294 | 261 | /> |
| 295 | 262 | ))} |
| 296 | 263 | |
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | Search, |
| 3 | − ShieldCheck, | |
| 4 | 3 | Filter, |
| 5 | 4 | Plus, |
| 6 | 5 | X, |
| @@ -29,13 +28,10 @@ interface ToolExplorerToolbarProps { · 22 unmodified lines | ||
| 29 | 28 | loadingInventory?: boolean; |
| 30 | 29 | filterApproval: FilterApproval; |
| 31 | 30 | activeSource: string | null; |
| 32 | − selectedToolCount: number; | |
| 33 | 31 | onSearchChange: (value: string) => void; |
| 34 | 32 | onClearSearch: () => void; |
| 35 | 33 | onFilterApprovalChange: (filter: FilterApproval) => void; |
| 36 | 34 | onAddSource?: () => void; |
| 37 | − onSelectAll: () => void; | |
| 38 | − onClearSelection: () => void; | |
| 39 | 35 | } |
| 40 | 36 | |
| 41 | 37 | export function ToolExplorerToolbar({ |
| @@ -43,18 +39,13 @@ export function ToolExplorerToolbar({ · 1 unmodified line | ||
| 43 | 39 | filteredToolCount, |
| 44 | 40 | loadingInventory = false, |
| 45 | 41 | filterApproval, |
| 46 | − selectedToolCount, | |
| 47 | 42 | onSearchChange, |
| 48 | 43 | onClearSearch, |
| 49 | 44 | onFilterApprovalChange, |
| 50 | 45 | onAddSource, |
| 51 | − onSelectAll, | |
| 52 | − onClearSelection, | |
| 53 | 46 | }: ToolExplorerToolbarProps) { |
| 54 | − const hasSelection = selectedToolCount > 0; | |
| 55 | − | |
| 56 | 47 | return ( |
| 57 | − <div className="shrink-0 relative space-y-1.5"> | |
| 48 | + <div className="shrink-0 space-y-1.5"> | |
| 58 | 49 | {/* Search — own row */} |
| 59 | 50 | <div className="relative"> |
| 60 | 51 | <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground/50" /> |
| @@ -135,46 +126,6 @@ export function ToolExplorerToolbar({ · 74 unmodified lines | ||
| 135 | 126 | </Button> |
| 136 | 127 | ) : null} |
| 137 | 128 | </div> |
| 138 | − | |
| 139 | − <div | |
| 140 | − className={cn( | |
| 141 | − "absolute inset-x-0 top-full mt-1 z-30 transition-opacity duration-150 pointer-events-none", | |
| 142 | − hasSelection ? "opacity-100 pointer-events-auto" : "opacity-0", | |
| 143 | − )} | |
| 144 | − aria-hidden={!hasSelection} | |
| 145 | − > | |
| 146 | − <div className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-primary/5 border border-primary/10"> | |
| 147 | − <span className="text-[11px] font-mono text-primary"> | |
| 148 | − {selectedToolCount} selected | |
| 149 | − </span> | |
| 150 | − <div className="flex-1" /> | |
| 151 | − <Button | |
| 152 | − variant="ghost" | |
| 153 | − size="sm" | |
| 154 | − className="h-5 text-[10px] text-muted-foreground px-1.5" | |
| 155 | − onClick={onSelectAll} | |
| 156 | − > | |
| 157 | − All ({filteredToolCount}) | |
| 158 | − </Button> | |
| 159 | − <Button | |
| 160 | − variant="ghost" | |
| 161 | − size="sm" | |
| 162 | − className="h-5 text-[10px] text-muted-foreground px-1.5" | |
| 163 | − onClick={onClearSelection} | |
| 164 | − > | |
| 165 | − Clear | |
| 166 | − </Button> | |
| 167 | − <div className="h-3 w-px bg-border/50" /> | |
| 168 | − <Button | |
| 169 | − variant="outline" | |
| 170 | − size="sm" | |
| 171 | − className="h-5 text-[10px] border-terminal-amber/30 text-terminal-amber hover:bg-terminal-amber/10 px-1.5" | |
| 172 | − > | |
| 173 | − <ShieldCheck className="h-2.5 w-2.5 mr-0.5" /> | |
| 174 | − Approval | |
| 175 | − </Button> | |
| 176 | − </div> | |
| 177 | − </div> | |
| 178 | 129 | </div> |
| 179 | 130 | ); |
| 180 | 131 | } |
| @@ -12,13 +12,11 @@ import type { ToolDescriptor, ToolSourceRecord } from "@/lib/types"; · 11 unmodified lines | ||
| 12 | 12 | import type { SourceAuthProfile } from "@/lib/types"; |
| 13 | 13 | import { |
| 14 | 14 | autoExpandedKeysForSearch, |
| 15 | − countSelectedTools, | |
| 16 | 15 | filterToolsBySearch, |
| 17 | 16 | filterToolsBySourceAndApproval, |
| 18 | 17 | treeGroupsForView, |
| 19 | 18 | type FilterApproval, |
| 20 | 19 | } from "./explorer-derived"; |
| 21 | −import { collectGroupKeys } from "@/lib/tool/explorer-grouping"; | |
| 22 | 20 | import { sourceLabel } from "@/lib/tool/source-utils"; |
| 23 | 21 | import { traverseSchema } from "@/lib/tool/schema-traverse"; |
| 24 | 22 | import { |
| @@ -79,12 +77,10 @@ interface ToolExplorerProps { · 54 unmodified lines | ||
| 79 | 77 | searchValue: string; |
| 80 | 78 | filterApprovalValue: FilterApproval; |
| 81 | 79 | focusedToolPathValue: string | null; |
| 82 | − selectedToolPathsValue: string[]; | |
| 83 | 80 | focusedSourceNameValue: string | null; |
| 84 | 81 | onSearchValueChange: (value: string) => void; |
| 85 | 82 | onFilterApprovalValueChange: (filter: FilterApproval) => void; |
| 86 | 83 | onFocusedToolPathChange: (toolPath: string | null) => void; |
| 87 | − onSelectedToolPathsChange: (toolPaths: string[]) => void; | |
| 88 | 84 | onFocusedSourceNameChange: (sourceName: string | null) => void; |
| 89 | 85 | onSourceAdded?: (source: ToolSourceRecord, options?: SourceAddedOptions) => void; |
| 90 | 86 | sourceDialogMeta?: Record<string, SourceDialogMeta>; |
| @@ -113,12 +109,10 @@ export function ToolExplorer({ · 22 unmodified lines | ||
| 113 | 109 | searchValue, |
| 114 | 110 | filterApprovalValue, |
| 115 | 111 | focusedToolPathValue, |
| 116 | − selectedToolPathsValue, | |
| 117 | 112 | focusedSourceNameValue, |
| 118 | 113 | onSearchValueChange, |
| 119 | 114 | onFilterApprovalValueChange, |
| 120 | 115 | onFocusedToolPathChange, |
| 121 | − onSelectedToolPathsChange, | |
| 122 | 116 | onFocusedSourceNameChange, |
| 123 | 117 | onSourceAdded, |
| 124 | 118 | sourceDialogMeta, |
| @@ -155,7 +149,6 @@ export function ToolExplorer({ · 30 unmodified lines | ||
| 155 | 149 | const searchInput = searchValue; |
| 156 | 150 | const filterApproval = filterApprovalValue; |
| 157 | 151 | const focusedToolPath = focusedToolPathValue; |
| 158 | − const selectedKeys = useMemo(() => new Set(selectedToolPathsValue), [selectedToolPathsValue]); | |
| 159 | 152 | const focusedSourceName = focusedSourceNameValue; |
| 160 | 153 | const search = useDeferredValue(searchInput); |
| 161 | 154 | |
| @@ -171,10 +164,6 @@ export function ToolExplorer({ · 9 unmodified lines | ||
| 171 | 164 | onFocusedToolPathChange(toolPath); |
| 172 | 165 | }, [onFocusedToolPathChange]); |
| 173 | 166 | |
| 174 | − const setSelectedKeys = useCallback((next: Set<string>) => { | |
| 175 | − onSelectedToolPathsChange(Array.from(next)); | |
| 176 | − }, [onSelectedToolPathsChange]); | |
| 177 | − | |
| 178 | 167 | const setFocusedSourceName = useCallback((sourceName: string | null) => { |
| 179 | 168 | onFocusedSourceNameChange(sourceName); |
| 180 | 169 | }, [onFocusedSourceNameChange]); |
| @@ -257,10 +246,6 @@ export function ToolExplorer({ · 76 unmodified lines | ||
| 257 | 246 | return filterToolsBySearch(filteredTools, search); |
| 258 | 247 | }, [filteredTools, search]); |
| 259 | 248 | |
| 260 | − const selectedToolCount = useMemo(() => { | |
| 261 | − return countSelectedTools(selectedKeys, filteredTools); | |
| 262 | − }, [selectedKeys, filteredTools]); | |
| 263 | − | |
| 264 | 249 | const sidebarExistingSourceNames = useMemo(() => { |
| 265 | 250 | return existingSourceNames ?? new Set(visibleSources.map((source) => source.name)); |
| 266 | 251 | }, [existingSourceNames, visibleSources]); |
| @@ -290,29 +275,6 @@ export function ToolExplorer({ · 23 unmodified lines | ||
| 290 | 275 | } |
| 291 | 276 | }, [focusedSourceNameValue, formSource, sourceByName, setFocusedToolPath]); |
| 292 | 277 | |
| 293 | − const toggleSelectTool = useCallback( | |
| 294 | − (path: string, e: React.MouseEvent) => { | |
| 295 | − e.stopPropagation(); | |
| 296 | − e.preventDefault(); | |
| 297 | − const next = new Set(selectedKeys); | |
| 298 | − if (next.has(path)) { | |
| 299 | − next.delete(path); | |
| 300 | − } else { | |
| 301 | − next.add(path); | |
| 302 | − } | |
| 303 | − setSelectedKeys(next); | |
| 304 | − }, | |
| 305 | − [selectedKeys, setSelectedKeys], | |
| 306 | − ); | |
| 307 | − | |
| 308 | − const clearSelection = useCallback(() => { | |
| 309 | − setSelectedKeys(new Set()); | |
| 310 | − }, []); | |
| 311 | − | |
| 312 | − const selectAll = useCallback(() => { | |
| 313 | − setSelectedKeys(new Set(filteredTools.map((t) => t.path))); | |
| 314 | − }, [filteredTools]); | |
| 315 | − | |
| 316 | 278 | const maybeLoadToolDetails = useCallback(async (tool: ToolDescriptor, expanded: boolean) => { |
| 317 | 279 | if (!expanded || !onLoadToolDetails) { |
| 318 | 280 | return; |
| @@ -459,14 +421,7 @@ export function ToolExplorer({ · 140 unmodified lines | ||
| 459 | 421 | }); |
| 460 | 422 | }, [searchedTools, loadingSources, visibleSources, sourceCounts, resolvedActiveSource]); |
| 461 | 423 | |
| 462 | − const [expandedKeys, setExpandedKeys] = useState<Set<string>>(() => { | |
| 463 | − // Start with all source-level groups expanded | |
| 464 | − const keys = new Set<string>(); | |
| 465 | − for (const group of treeGroups) { | |
| 466 | − keys.add(group.key); | |
| 467 | − } | |
| 468 | − return keys; | |
| 469 | − }); | |
| 424 | + const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set()); | |
| 470 | 425 | |
| 471 | 426 | // Auto-expand all groups when search is active |
| 472 | 427 | const searchAutoExpanded = useMemo( |
| @@ -476,21 +431,6 @@ export function ToolExplorer({ · 3 unmodified lines | ||
| 476 | 431 | |
| 477 | 432 | const effectiveExpandedKeys = searchAutoExpanded ?? expandedKeys; |
| 478 | 433 | |
| 479 | − // When new source groups appear, auto-expand them | |
| 480 | − useEffect(() => { | |
| 481 | − const allKeys = collectGroupKeys(treeGroups); | |
| 482 | − setExpandedKeys((prev) => { | |
| 483 | − const next = new Set(prev); | |
| 484 | − let changed = false; | |
| 485 | − for (const key of allKeys) { | |
| 486 | − if (key.startsWith("source:") && !key.includes(":ns:") && !next.has(key)) { | |
| 487 | − next.add(key); | |
| 488 | − changed = true; | |
| 489 | − } | |
| 490 | − } | |
| 491 | − return changed ? next : prev; | |
| 492 | − }); | |
| 493 | − }, [treeGroups]); | |
| 494 | 434 | |
| 495 | 435 | const toggleExpand = useCallback((key: string) => { |
| 496 | 436 | setExpandedKeys((prev) => { |
| @@ -609,13 +549,10 @@ export function ToolExplorer({ · 112 unmodified lines | ||
| 609 | 549 | loadingInventory={awaitingInitialInventory} |
| 610 | 550 | filterApproval={filterApproval} |
| 611 | 551 | activeSource={resolvedActiveSource} |
| 612 | − selectedToolCount={selectedToolCount} | |
| 613 | 552 | onSearchChange={setSearchInput} |
| 614 | 553 | onClearSearch={() => setSearchInput("")} |
| 615 | 554 | onFilterApprovalChange={setFilterApproval} |
| 616 | 555 | onAddSource={handleAddSource} |
| 617 | − onSelectAll={selectAll} | |
| 618 | − onClearSelection={clearSelection} | |
| 619 | 556 | /> |
| 620 | 557 | </div> |
| 621 | 558 | |
| @@ -659,9 +596,7 @@ export function ToolExplorer({ · 37 unmodified lines | ||
| 659 | 596 | onToggle={toggleExpand} |
| 660 | 597 | focusedPath={focusedToolPath} |
| 661 | 598 | focusedSource={focusedSourceName} |
| 662 | − selectedKeys={selectedKeys} | |
| 663 | 599 | onFocusTool={handleFocusTool} |
| 664 | − onSelectTool={toggleSelectTool} | |
| 665 | 600 | onSourceClick={handleSourceClick} |
| 666 | 601 | source={sourceByName.get(group.label) ?? undefined} |
| 667 | 602 | /> |
| @@ -69,9 +69,8 @@ export function SourceFormPanel({ · 68 unmodified lines | ||
| 69 | 69 | onClose: () => void; |
| 70 | 70 | }) { |
| 71 | 71 | const { context } = useSession(); |
| 72 | − const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); | |
| 72 | + const upsertToolSource = useAction(convexApi.workspace.upsertToolSource); | |
| 73 | 73 | const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); |
| 74 | − const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); | |
| 75 | 74 | const previewOpenApiSourceUpgrade = useAction(convexApi.executorNode.previewOpenApiSourceUpgrade); |
| 76 | 75 | const credentials = useQuery( |
| 77 | 76 | convexApi.workspace.listCredentials, |
| @@ -195,7 +194,6 @@ export function SourceFormPanel({ · 117 unmodified lines | ||
| 195 | 194 | sourceToEdit, |
| 196 | 195 | credentialsLoading, |
| 197 | 196 | upsertToolSource, |
| 198 | − upsertCredential, | |
| 199 | 197 | form: { |
| 200 | 198 | name: form.name, |
| 201 | 199 | endpoint: form.endpoint, |
| @@ -336,10 +334,10 @@ export function SourceFormPanel({ · 134 unmodified lines | ||
| 336 | 334 | <ArrowLeft className="h-3.5 w-3.5" /> |
| 337 | 335 | </button> |
| 338 | 336 | <div className="min-w-0 flex-1"> |
| 339 | − <h2 className="text-[14px] font-mono font-semibold text-foreground"> | |
| 337 | + <h2 className="text-sm font-semibold text-foreground"> | |
| 340 | 338 | {editing ? "Edit source" : "Add source"} |
| 341 | 339 | </h2> |
| 342 | − <p className="text-[10px] text-muted-foreground/60 mt-0.5"> | |
| 340 | + <p className="mt-0.5 text-xs text-muted-foreground/70"> | |
| 343 | 341 | {editing |
| 344 | 342 | ? "Update endpoint, auth, and credentials." |
| 345 | 343 | : "Connect a source and configure credentials."} |
| @@ -365,7 +363,7 @@ export function SourceFormPanel({ · 19 unmodified lines | ||
| 365 | 363 | <p className="text-[13px] font-medium truncate"> |
| 366 | 364 | {displaySourceName(sourceToEdit.name)} |
| 367 | 365 | </p> |
| 368 | − <p className="text-[10px] text-muted-foreground truncate mt-0.5"> | |
| 366 | + <p className="mt-0.5 truncate text-xs text-muted-foreground"> | |
| 369 | 367 | {compactEndpointLabel(sourceToEdit)} |
| 370 | 368 | </p> |
| 371 | 369 | <div className="mt-2 flex items-center gap-1.5 flex-wrap"> |
| @@ -86,7 +86,7 @@ function ConstraintBadges({ entry }: { entry: SchemaFieldEntry }) { · 85 unmodified lines | ||
| 86 | 86 | return ( |
| 87 | 87 | <span className="inline-flex items-center gap-1 ml-0.5"> |
| 88 | 88 | {parts.map((p, i) => ( |
| 89 | − <span key={i} className="font-mono text-[9px] text-muted-foreground/65 bg-muted rounded-sm px-1 leading-[1.4]"> | |
| 89 | + <span key={i} className="rounded-sm bg-muted px-1 text-[11px] leading-[1.4] text-muted-foreground/70"> | |
| 90 | 90 | {p} |
| 91 | 91 | </span> |
| 92 | 92 | ))} |
| @@ -104,7 +104,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 11 unmodified lines | ||
| 104 | 104 | > |
| 105 | 105 | {/* Name + type line */} |
| 106 | 106 | <div className="flex items-center gap-1.5 flex-wrap"> |
| 107 | − <span className="font-mono text-[11px] font-semibold text-foreground leading-tight"> | |
| 107 | + <span className="text-sm font-semibold leading-tight text-foreground"> | |
| 108 | 108 | {entry.path.split(".").pop()} |
| 109 | 109 | </span> |
| 110 | 110 | |
| @@ -117,7 +117,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 6 unmodified lines | ||
| 117 | 117 | |
| 118 | 118 | <span |
| 119 | 119 | className={cn( |
| 120 | − "font-mono text-[10px] leading-none px-1.5 py-0.5 rounded-[3px] border font-medium", | |
| 120 | + "rounded-[3px] border px-1.5 py-0.5 text-[11px] leading-none font-medium", | |
| 121 | 121 | typeColorClasses(entry.type), |
| 122 | 122 | )} |
| 123 | 123 | > |
| @@ -125,7 +125,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 1 unmodified line | ||
| 125 | 125 | </span> |
| 126 | 126 | |
| 127 | 127 | {entry.deprecated ? ( |
| 128 | − <span className="font-mono text-[9px] font-medium uppercase tracking-wide text-[oklch(0.6_0.16_75)] dark:text-[oklch(0.78_0.14_75)] bg-[oklch(0.6_0.16_75_/_0.08)] dark:bg-[oklch(0.78_0.14_75_/_0.1)] border border-[oklch(0.6_0.16_75_/_0.18)] dark:border-[oklch(0.78_0.14_75_/_0.2)] rounded-[3px] px-1.5 py-px leading-none"> | |
| 128 | + <span className="rounded-[3px] border border-[oklch(0.6_0.16_75_/_0.18)] bg-[oklch(0.6_0.16_75_/_0.08)] px-1.5 py-px text-[10px] font-medium leading-none text-[oklch(0.6_0.16_75)] dark:border-[oklch(0.78_0.14_75_/_0.2)] dark:bg-[oklch(0.78_0.14_75_/_0.1)] dark:text-[oklch(0.78_0.14_75)]"> | |
| 129 | 129 | deprecated |
| 130 | 130 | </span> |
| 131 | 131 | ) : null} |
| @@ -135,7 +135,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 3 unmodified lines | ||
| 135 | 135 | |
| 136 | 136 | {/* Description */} |
| 137 | 137 | {entry.description ? ( |
| 138 | − <div className="text-[11px] leading-relaxed text-muted-foreground [&_p]:m-0 [&_p+p]:mt-0.5 [&_code]:font-mono [&_code]:text-[10px] [&_code]:bg-muted [&_code]:border [&_code]:border-border/70 [&_code]:rounded-sm [&_code]:px-1 [&_code]:py-px [&_code]:text-terminal-cyan [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_a]:decoration-primary/25 hover:[&_a]:decoration-primary/70"> | |
| 138 | + <div className="text-xs leading-relaxed text-muted-foreground [&_p]:m-0 [&_p+p]:mt-0.5 [&_code]:font-mono [&_code]:text-[11px] [&_code]:bg-muted [&_code]:border [&_code]:border-border/70 [&_code]:rounded-sm [&_code]:px-1 [&_code]:py-px [&_code]:text-primary [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_a]:decoration-primary/25 hover:[&_a]:decoration-primary/70"> | |
| 139 | 139 | <Streamdown plugins={{ code: codePlugin }} controls={false}>{entry.description}</Streamdown> |
| 140 | 140 | </div> |
| 141 | 141 | ) : null} |
| @@ -144,7 +144,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 2 unmodified lines | ||
| 144 | 144 | {entry.enumValues && entry.enumValues.length > 0 && entry.enumValues.length <= 12 ? ( |
| 145 | 145 | <div className="flex flex-wrap gap-1 mt-0.5"> |
| 146 | 146 | {entry.enumValues.map((v, i) => ( |
| 147 | − <code key={i} className="font-mono text-[10px] bg-muted border border-border/60 rounded-[3px] px-1.5 py-px text-foreground/80"> | |
| 147 | + <code key={i} className="rounded-[3px] border border-border/60 bg-muted px-1.5 py-px font-mono text-[11px] text-foreground/80"> | |
| 148 | 148 | {v} |
| 149 | 149 | </code> |
| 150 | 150 | ))} |
| @@ -156,14 +156,14 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 5 unmodified lines | ||
| 156 | 156 | <div className="flex flex-wrap gap-2 mt-0.5"> |
| 157 | 157 | {entry.example ? ( |
| 158 | 158 | <span className="inline-flex items-center gap-1"> |
| 159 | − <span className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground/50">example</span> | |
| 160 | − <code className="font-mono text-[10px] text-muted-foreground/85">{entry.example}</code> | |
| 159 | + <span className="text-[11px] text-muted-foreground/60">Example</span> | |
| 160 | + <code className="font-mono text-xs text-muted-foreground/85">{entry.example}</code> | |
| 161 | 161 | </span> |
| 162 | 162 | ) : null} |
| 163 | 163 | {entry.defaultValue ? ( |
| 164 | 164 | <span className="inline-flex items-center gap-1"> |
| 165 | − <span className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground/50">default</span> | |
| 166 | − <code className="font-mono text-[10px] text-muted-foreground/85">{entry.defaultValue}</code> | |
| 165 | + <span className="text-[11px] text-muted-foreground/60">Default</span> | |
| 166 | + <code className="font-mono text-xs text-muted-foreground/85">{entry.defaultValue}</code> | |
| 167 | 167 | </span> |
| 168 | 168 | ) : null} |
| 169 | 169 | </div> |
| @@ -212,11 +212,11 @@ function SchemaFieldsSection({ · 42 unmodified lines | ||
| 212 | 212 | <div className="flex flex-col gap-2"> |
| 213 | 213 | {/* Section header */} |
| 214 | 214 | <div className="flex items-baseline gap-2"> |
| 215 | − <span className="font-mono text-[9px] font-medium tracking-wider uppercase text-muted-foreground/60"> | |
| 215 | + <span className="text-xs font-medium text-muted-foreground/70"> | |
| 216 | 216 | {label} |
| 217 | 217 | </span> |
| 218 | 218 | {hasEntries ? ( |
| 219 | − <span className="font-mono text-[10px] text-muted-foreground/50"> | |
| 219 | + <span className="text-xs text-muted-foreground/60"> | |
| 220 | 220 | {entries.length} field{entries.length !== 1 ? "s" : ""} |
| 221 | 221 | {truncated ? "+" : ""} |
| 222 | 222 | </span> |
| @@ -228,7 +228,7 @@ function SchemaFieldsSection({ · 5 unmodified lines | ||
| 228 | 228 | <div className="border border-border rounded-md bg-muted/30 overflow-hidden"> |
| 229 | 229 | {collapsedByDefault ? ( |
| 230 | 230 | <details open={false}> |
| 231 | − <summary className="cursor-pointer select-none font-mono text-[10px] text-muted-foreground/60 px-3.5 py-2 block transition-opacity hover:opacity-100"> | |
| 231 | + <summary className="block cursor-pointer select-none px-3.5 py-2 text-xs text-muted-foreground/70 transition-opacity hover:opacity-100"> | |
| 232 | 232 | Show all fields ({entries.length}{truncated ? "+" : ""}) |
| 233 | 233 | </summary> |
| 234 | 234 | <div className="flex flex-col"> |
| @@ -236,7 +236,7 @@ function SchemaFieldsSection({ · 1 unmodified line | ||
| 236 | 236 | <FieldRow key={`${entry.path}-${i}`} entry={entry} /> |
| 237 | 237 | ))} |
| 238 | 238 | {truncated ? ( |
| 239 | − <p className="font-mono text-[10px] text-muted-foreground/50 px-3.5 py-2"> | |
| 239 | + <p className="px-3.5 py-2 text-xs text-muted-foreground/60"> | |
| 240 | 240 | Showing first {entries.length} fields... |
| 241 | 241 | </p> |
| 242 | 242 | ) : null} |
| @@ -251,7 +251,7 @@ function SchemaFieldsSection({ · 8 unmodified lines | ||
| 251 | 251 | )} |
| 252 | 252 | </div> |
| 253 | 253 | ) : empty ? ( |
| 254 | − <p className="text-[11px] font-mono text-muted-foreground/50 italic pl-0.5"> | |
| 254 | + <p className="pl-0.5 text-xs italic text-muted-foreground/60"> | |
| 255 | 255 | Empty object |
| 256 | 256 | </p> |
| 257 | 257 | ) : schemaJson ? ( |
| @@ -261,7 +261,7 @@ function SchemaFieldsSection({ · 3 unmodified lines | ||
| 261 | 261 | {/* Raw schema toggle */} |
| 262 | 262 | {hasEntries && schemaJson ? ( |
| 263 | 263 | <details className="mt-1"> |
| 264 | − <summary className="cursor-pointer select-none font-mono text-[9px] uppercase tracking-wider text-muted-foreground/50 transition-opacity hover:opacity-80"> | |
| 264 | + <summary className="cursor-pointer select-none text-xs font-medium text-muted-foreground/60 transition-opacity hover:opacity-80"> | |
| 265 | 265 | Raw schema |
| 266 | 266 | </summary> |
| 267 | 267 | <div className="mt-1.5"> |
| @@ -308,11 +308,11 @@ export function ToolDetailPanel({ · 40 unmodified lines | ||
| 308 | 308 | <div className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-border/40 px-5 py-3"> |
| 309 | 309 | <div className="flex items-center gap-2.5 min-w-0"> |
| 310 | 310 | <Zap className="h-3.5 w-3.5 text-primary/70 shrink-0" /> |
| 311 | − <h2 className="text-[14px] font-mono font-semibold text-foreground truncate"> | |
| 311 | + <h2 className="truncate text-sm font-semibold text-foreground"> | |
| 312 | 312 | {tool.path} |
| 313 | 313 | </h2> |
| 314 | 314 | {tool.approval === "required" ? ( |
| 315 | − <span className="inline-flex items-center gap-0.5 text-[9px] font-mono uppercase tracking-wider text-terminal-amber bg-terminal-amber/8 px-1.5 py-0.5 rounded border border-terminal-amber/15 shrink-0"> | |
| 315 | + <span className="inline-flex shrink-0 items-center gap-0.5 rounded border border-terminal-amber/15 bg-terminal-amber/8 px-1.5 py-0.5 text-xs font-medium text-terminal-amber"> | |
| 316 | 316 | <ShieldCheck className="h-2.5 w-2.5" /> |
| 317 | 317 | gated |
| 318 | 318 | </span> |
| @@ -322,7 +322,7 @@ export function ToolDetailPanel({ · 3 unmodified lines | ||
| 322 | 322 | </div> |
| 323 | 323 | </div> |
| 324 | 324 | {tool.source ? ( |
| 325 | − <p className="mt-0.5 text-[10px] font-mono text-muted-foreground/50 pl-6"> | |
| 325 | + <p className="mt-0.5 pl-6 text-xs text-muted-foreground/60"> | |
| 326 | 326 | {tool.source} |
| 327 | 327 | </p> |
| 328 | 328 | ) : null} |
| @@ -404,11 +404,11 @@ function ToolDetailContent({ · 75 unmodified lines | ||
| 404 | 404 | <div className="space-y-3"> |
| 405 | 405 | <Skeleton className="h-3.5 w-72" /> |
| 406 | 406 | <div> |
| 407 | − <p className="font-mono text-[9px] font-medium tracking-wider uppercase text-muted-foreground/60">Arguments</p> | |
| 407 | + <p className="text-xs font-medium text-muted-foreground/70">Arguments</p> | |
| 408 | 408 | <Skeleton className="h-20 w-full rounded-md mt-1" /> |
| 409 | 409 | </div> |
| 410 | 410 | <div> |
| 411 | − <p className="font-mono text-[9px] font-medium tracking-wider uppercase text-muted-foreground/60">Returns</p> | |
| 411 | + <p className="text-xs font-medium text-muted-foreground/70">Returns</p> | |
| 412 | 412 | <Skeleton className="h-14 w-full rounded-md mt-1" /> |
| 413 | 413 | </div> |
| 414 | 414 | </div> |
| @@ -577,22 +577,22 @@ function ToolPicker({ · 576 unmodified lines | ||
| 577 | 577 | variant="outline" |
| 578 | 578 | role="combobox" |
| 579 | 579 | aria-expanded={open} |
| 580 | − className="h-9 w-full justify-between text-xs font-mono bg-background hover:bg-muted/50 border-border/70" | |
| 580 | + className="h-9 w-full justify-between bg-background text-sm hover:bg-muted/50 border-border/70" | |
| 581 | 581 | > |
| 582 | 582 | <span className="truncate text-left">{selectionSummary}</span> |
| 583 | 583 | <ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" /> |
| 584 | 584 | </Button> |
| 585 | 585 | </PopoverTrigger> |
| 586 | 586 | <PopoverContent className="w-[460px] p-0" align="start"> |
| 587 | − {/* Search input — raw input, deferred for filtering so typing never lags */} | |
| 587 | + {/* Search input — deferred for filtering so typing never lags */} | |
| 588 | 588 | <div className="flex h-9 items-center gap-2 border-b px-3"> |
| 589 | 589 | <Search className="size-4 shrink-0 opacity-50" /> |
| 590 | − <input | |
| 590 | + <Input | |
| 591 | 591 | ref={searchRef} |
| 592 | 592 | value={searchInput} |
| 593 | 593 | onChange={(e) => setSearchInput(e.target.value)} |
| 594 | 594 | placeholder="Search tools..." |
| 595 | − className="flex h-10 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50" | |
| 595 | + className="h-8 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0" | |
| 596 | 596 | /> |
| 597 | 597 | {searchInput && ( |
| 598 | 598 | <button |
| @@ -616,11 +616,11 @@ function ToolPicker({ · 17 unmodified lines | ||
| 616 | 616 | {/* Footer */} |
| 617 | 617 | {selectedPaths.length > 0 && ( |
| 618 | 618 | <div className="border-t border-border/50 p-2 flex items-center justify-between"> |
| 619 | − <span className="text-[10px] text-muted-foreground">{selectedPaths.length} selected</span> | |
| 619 | + <span className="text-xs text-muted-foreground">{selectedPaths.length} selected</span> | |
| 620 | 620 | <Button |
| 621 | 621 | variant="ghost" |
| 622 | 622 | size="sm" |
| 623 | − className="h-6 text-[10px] px-2" | |
| 623 | + className="h-6 px-2 text-xs" | |
| 624 | 624 | onClick={() => { onSelectionChange([]); onPatternChange("*"); }} |
| 625 | 625 | > |
| 626 | 626 | Clear |
| @@ -1286,97 +1286,99 @@ export function PoliciesPanel({ · 659 unmodified lines | ||
| 1286 | 1286 | // ── Render ── |
| 1287 | 1287 | |
| 1288 | 1288 | return ( |
| 1289 | − <section className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col border border-border/50 bg-card/40"> | |
| 1290 | − <div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5"> | |
| 1291 | − <div className="space-y-4"> | |
| 1292 | − {/* Header */} | |
| 1293 | − <div className="flex items-center justify-between"> | |
| 1294 | − <div className="flex items-center gap-2.5"> | |
| 1295 | − <Shield className="h-4 w-4 text-muted-foreground" /> | |
| 1296 | − <div> | |
| 1297 | − <h3 className="text-sm font-medium leading-none">Tool Policies</h3> | |
| 1298 | − <p className="text-[11px] text-muted-foreground mt-1"> | |
| 1299 | − Control which tools or sources are approved, gated, or blocked | |
| 1300 | − </p> | |
| 1289 | + <section className="flex h-full min-h-0 w-full overflow-hidden bg-background"> | |
| 1290 | + {/* ── Left sidebar ── */} | |
| 1291 | + <aside className="flex h-full w-64 shrink-0 flex-col border-r border-border/40 bg-card/30 lg:w-72"> | |
| 1292 | + {/* Sidebar header */} | |
| 1293 | + <div className="shrink-0 border-b border-border/30 px-3 py-2.5"> | |
| 1294 | + <div className="flex items-center justify-between"> | |
| 1295 | + <div className="flex items-center gap-2"> | |
| 1296 | + <Shield className="h-4 w-4 text-muted-foreground" /> | |
| 1297 | + <h3 className="text-sm font-medium leading-none">Policies</h3> | |
| 1298 | + </div> | |
| 1299 | + <Button | |
| 1300 | + onClick={() => { setForm(defaultFormState()); setDialogOpen(true); }} | |
| 1301 | + size="sm" | |
| 1302 | + variant="ghost" | |
| 1303 | + className="h-7 w-7 p-0" | |
| 1304 | + disabled={!context} | |
| 1305 | + > | |
| 1306 | + <Plus className="h-3.5 w-3.5" /> | |
| 1307 | + </Button> | |
| 1301 | 1308 | </div> |
| 1309 | + <p className="text-[10px] text-muted-foreground mt-1"> | |
| 1310 | + {policies.length} {policies.length === 1 ? "policy" : "policies"} | |
| 1311 | + </p> | |
| 1302 | 1312 | </div> |
| 1303 | − <Button | |
| 1304 | − onClick={() => { setForm(defaultFormState()); setDialogOpen(true); }} | |
| 1305 | − size="sm" | |
| 1306 | − className="h-8 text-xs gap-1.5" | |
| 1307 | − disabled={!context} | |
| 1308 | − > | |
| 1309 | − <Plus className="h-3.5 w-3.5" /> | |
| 1310 | − Quick Policy | |
| 1311 | − </Button> | |
| 1312 | − </div> | |
| 1313 | − | |
| 1314 | − <Separator className="bg-border/40" /> | |
| 1315 | 1313 | |
| 1316 | − {/* Policies list */} | |
| 1317 | − {loading || loadingTools ? ( | |
| 1318 | − <div className="space-y-2"> | |
| 1319 | − {Array.from({ length: 3 }).map((_, i) => ( | |
| 1320 | − <Skeleton key={i} className="h-16 rounded-lg" /> | |
| 1321 | − ))} | |
| 1322 | − </div> | |
| 1323 | − ) : policies.length === 0 ? ( | |
| 1324 | − <div className="rounded-none border border-dashed border-border/50 py-10 flex flex-col items-center gap-2.5"> | |
| 1325 | − <Shield className="h-8 w-8 text-muted-foreground/30" /> | |
| 1326 | − <div className="text-center"> | |
| 1327 | − <p className="text-xs text-muted-foreground">No tool policies configured</p> | |
| 1328 | − <p className="text-[11px] text-muted-foreground/60 mt-0.5"> | |
| 1329 | − Tools use default approval behavior. Create a tool policy to customize it. | |
| 1330 | − </p> | |
| 1331 | − </div> | |
| 1332 | − <Button | |
| 1333 | − variant="outline" | |
| 1334 | − size="sm" | |
| 1335 | − className="h-7 text-[11px] gap-1 mt-1" | |
| 1336 | − onClick={() => { setForm(defaultFormState()); setDialogOpen(true); }} | |
| 1337 | − > | |
| 1338 | − <Plus className="h-3 w-3" /> | |
| 1339 | − Create first policy | |
| 1340 | − </Button> | |
| 1341 | − </div> | |
| 1342 | − ) : ( | |
| 1343 | − <div className="space-y-4"> | |
| 1344 | − {(["deny", "require_approval", "allow"] as const).map((decisionType) => { | |
| 1345 | − const group = groupedPolicies[decisionType]; | |
| 1346 | − if (group.length === 0) return null; | |
| 1347 | − const config = DECISION_CONFIG[decisionType]; | |
| 1348 | − return ( | |
| 1349 | − <div key={decisionType}> | |
| 1350 | − <div className="flex items-center gap-2 mb-2"> | |
| 1351 | − <config.icon className={cn("h-3 w-3", config.color)} /> | |
| 1352 | − <span className={cn("text-[11px] font-medium uppercase tracking-wider", config.color)}> | |
| 1353 | − {config.label} | |
| 1354 | − </span> | |
| 1355 | − <span className="text-[10px] text-muted-foreground/40"> | |
| 1356 | − ({group.length}) | |
| 1357 | − </span> | |
| 1358 | − </div> | |
| 1359 | − <div className="space-y-1.5"> | |
| 1360 | − {group.map((policy) => ( | |
| 1361 | − <PolicyCard | |
| 1362 | − key={policy.id} | |
| 1363 | − policy={policy} | |
| 1364 | − tools={tools} | |
| 1365 | − currentAccountId={context?.accountId} | |
| 1366 | − onDelete={handleDelete} | |
| 1367 | − canDelete={isDirectToolPolicy(policy)} | |
| 1368 | − deleting={deletingId === policy.id} | |
| 1369 | − /> | |
| 1370 | − ))} | |
| 1371 | − </div> | |
| 1314 | + {/* Sidebar policy list */} | |
| 1315 | + <div className="flex-1 min-h-0 overflow-y-auto"> | |
| 1316 | + {loading || loadingTools ? ( | |
| 1317 | + <div className="space-y-2 p-3"> | |
| 1318 | + {Array.from({ length: 4 }).map((_, i) => ( | |
| 1319 | + <Skeleton key={i} className="h-12 rounded-md" /> | |
| 1320 | + ))} | |
| 1321 | + </div> | |
| 1322 | + ) : policies.length === 0 ? ( | |
| 1323 | + <div className="flex flex-col items-center gap-2.5 px-4 py-10"> | |
| 1324 | + <Shield className="h-7 w-7 text-muted-foreground/30" /> | |
| 1325 | + <div className="text-center"> | |
| 1326 | + <p className="text-xs text-muted-foreground">No policies</p> | |
| 1327 | + <p className="text-[10px] text-muted-foreground/60 mt-0.5"> | |
| 1328 | + Create a policy to customize tool approval behavior. | |
| 1329 | + </p> | |
| 1372 | 1330 | </div> |
| 1373 | − ); | |
| 1374 | − })} | |
| 1331 | + <Button | |
| 1332 | + variant="outline" | |
| 1333 | + size="sm" | |
| 1334 | + className="h-7 text-[11px] gap-1 mt-1" | |
| 1335 | + onClick={() => { setForm(defaultFormState()); setDialogOpen(true); }} | |
| 1336 | + > | |
| 1337 | + <Plus className="h-3 w-3" /> | |
| 1338 | + Create first policy | |
| 1339 | + </Button> | |
| 1340 | + </div> | |
| 1341 | + ) : ( | |
| 1342 | + <div className="p-2 space-y-3"> | |
| 1343 | + {(["deny", "require_approval", "allow"] as const).map((decisionType) => { | |
| 1344 | + const group = groupedPolicies[decisionType]; | |
| 1345 | + if (group.length === 0) return null; | |
| 1346 | + const config = DECISION_CONFIG[decisionType]; | |
| 1347 | + return ( | |
| 1348 | + <div key={decisionType}> | |
| 1349 | + <div className="flex items-center gap-2 mb-1.5 px-1"> | |
| 1350 | + <config.icon className={cn("h-3 w-3", config.color)} /> | |
| 1351 | + <span className={cn("text-[10px] font-medium uppercase tracking-wider", config.color)}> | |
| 1352 | + {config.label} | |
| 1353 | + </span> | |
| 1354 | + <span className="text-[9px] text-muted-foreground/40"> | |
| 1355 | + ({group.length}) | |
| 1356 | + </span> | |
| 1357 | + </div> | |
| 1358 | + <div className="space-y-1"> | |
| 1359 | + {group.map((policy) => ( | |
| 1360 | + <PolicyCard | |
| 1361 | + key={policy.id} | |
| 1362 | + policy={policy} | |
| 1363 | + tools={tools} | |
| 1364 | + currentAccountId={context?.accountId} | |
| 1365 | + onDelete={handleDelete} | |
| 1366 | + canDelete={isDirectToolPolicy(policy)} | |
| 1367 | + deleting={deletingId === policy.id} | |
| 1368 | + /> | |
| 1369 | + ))} | |
| 1370 | + </div> | |
| 1371 | + </div> | |
| 1372 | + ); | |
| 1373 | + })} | |
| 1374 | + </div> | |
| 1375 | + )} | |
| 1375 | 1376 | </div> |
| 1376 | − )} | |
| 1377 | − | |
| 1378 | − <Separator className="bg-border/40" /> | |
| 1377 | + </aside> | |
| 1379 | 1378 | |
| 1379 | + {/* ── Right content panel ── */} | |
| 1380 | + <div className="flex-1 min-w-0 max-h-screen overflow-y-auto bg-background/50"> | |
| 1381 | + <div className="p-4 sm:p-5 space-y-4"> | |
| 1380 | 1382 | <details className="group rounded-none border border-border/60 bg-card/30"> |
| 1381 | 1383 | <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2.5"> |
| 1382 | 1384 | <div> |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | KeyRound, |
| 9 | 9 | Table, |
| 10 | 10 | } from "lucide-react"; |
| 11 | +import { Skeleton } from "@/components/ui/skeleton"; | |
| 11 | 12 | import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; |
| 12 | 13 | import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types"; |
| 13 | 14 | import { convexApi } from "@/lib/convex-api"; |
| @@ -32,6 +33,37 @@ import { · 18 unmodified lines | ||
| 32 | 33 | type StorageSqlResult, |
| 33 | 34 | } from "./storage_panel_shared"; |
| 34 | 35 | |
| 36 | +function StorageInspectorSkeleton() { | |
| 37 | + return ( | |
| 38 | + <div className="flex h-full min-h-0 flex-col"> | |
| 39 | + <div className="flex items-center justify-between border-b border-border/40 px-4 py-1.5 bg-card/20"> | |
| 40 | + <div className="space-y-1"> | |
| 41 | + <Skeleton className="h-3 w-40 rounded" /> | |
| 42 | + <Skeleton className="h-2.5 w-56 rounded" /> | |
| 43 | + </div> | |
| 44 | + <div className="flex items-center gap-1.5"> | |
| 45 | + <Skeleton className="h-7 w-12 rounded-md" /> | |
| 46 | + <Skeleton className="h-7 w-12 rounded-md" /> | |
| 47 | + <Skeleton className="h-7 w-12 rounded-md" /> | |
| 48 | + </div> | |
| 49 | + </div> | |
| 50 | + | |
| 51 | + <div className="flex items-center gap-2 border-b border-border/30 px-4 py-2 bg-muted/20"> | |
| 52 | + <Skeleton className="h-7 flex-1 rounded-md" /> | |
| 53 | + <Skeleton className="h-7 w-16 rounded-md" /> | |
| 54 | + </div> | |
| 55 | + | |
| 56 | + <div className="flex-1 min-h-0 overflow-hidden p-4"> | |
| 57 | + <div className="space-y-2"> | |
| 58 | + {Array.from({ length: 6 }).map((_, index) => ( | |
| 59 | + <Skeleton key={index} className="h-9 w-full rounded-md" /> | |
| 60 | + ))} | |
| 61 | + </div> | |
| 62 | + </div> | |
| 63 | + </div> | |
| 64 | + ); | |
| 65 | +} | |
| 66 | + | |
| 35 | 67 | // ── Main Component ───────────────────────────────────────────────────────── |
| 36 | 68 | |
| 37 | 69 | export function StoragePanel({ |
| @@ -478,6 +510,8 @@ export function StoragePanel({ · 440 unmodified lines | ||
| 478 | 510 | onSqlViewModeChange={setSqlViewMode} |
| 479 | 511 | /> |
| 480 | 512 | </Tabs> |
| 513 | + ) : loading ? ( | |
| 514 | + <StorageInspectorSkeleton /> | |
| 481 | 515 | ) : ( |
| 482 | 516 | <div className="flex h-full items-center justify-center"> |
| 483 | 517 | <div className="flex flex-col items-center gap-3"> |
| @@ -2,6 +2,13 @@ import { Database, Plus, Power, Trash2, X } from "lucide-react"; · 1 unmodified line | ||
| 2 | 2 | import { Badge } from "@/components/ui/badge"; |
| 3 | 3 | import { Button } from "@/components/ui/button"; |
| 4 | 4 | import { Input } from "@/components/ui/input"; |
| 5 | +import { | |
| 6 | + Select, | |
| 7 | + SelectContent, | |
| 8 | + SelectItem, | |
| 9 | + SelectTrigger, | |
| 10 | + SelectValue, | |
| 11 | +} from "@/components/ui/select"; | |
| 5 | 12 | import { Skeleton } from "@/components/ui/skeleton"; |
| 6 | 13 | import { cn } from "@/lib/utils"; |
| 7 | 14 | import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types"; |
| @@ -59,7 +66,7 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 51 unmodified lines | ||
| 59 | 66 | <Database className="h-3.5 w-3.5 text-muted-foreground" /> |
| 60 | 67 | <span className="text-xs font-medium text-foreground">Instances</span> |
| 61 | 68 | {visibleInstances.length > 0 && ( |
| 62 | − <Badge variant="outline" className="h-4 px-1.5 text-[9px] font-mono text-muted-foreground"> | |
| 69 | + <Badge variant="outline" className="h-4 px-1.5 text-xs text-muted-foreground"> | |
| 63 | 70 | {visibleInstances.length} |
| 64 | 71 | </Badge> |
| 65 | 72 | )} |
| @@ -78,36 +85,44 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 12 unmodified lines | ||
| 78 | 85 | <div className="border-b border-border/40 bg-muted/30 px-3 py-3"> |
| 79 | 86 | <div className="space-y-2"> |
| 80 | 87 | <div className="grid grid-cols-2 gap-2"> |
| 81 | − <label className="flex flex-col gap-1 text-[10px] text-muted-foreground"> | |
| 88 | + <label className="flex flex-col gap-1 text-xs text-muted-foreground"> | |
| 82 | 89 | Scope |
| 83 | − <select | |
| 84 | − className="h-7 rounded-md border border-border/60 bg-background px-2 text-[11px] text-foreground" | |
| 90 | + <Select | |
| 85 | 91 | value={scopeType} |
| 86 | − onChange={(event) => onScopeTypeChange(event.target.value as StorageScopeType)} | |
| 92 | + onValueChange={(value) => onScopeTypeChange(value as StorageScopeType)} | |
| 87 | 93 | > |
| 88 | − <option value="scratch">scratch</option> | |
| 89 | − <option value="account">account</option> | |
| 90 | − <option value="workspace">workspace</option> | |
| 91 | − <option value="organization">organization</option> | |
| 92 | − </select> | |
| 94 | + <SelectTrigger className="h-8 w-full"> | |
| 95 | + <SelectValue /> | |
| 96 | + </SelectTrigger> | |
| 97 | + <SelectContent> | |
| 98 | + <SelectItem value="scratch">scratch</SelectItem> | |
| 99 | + <SelectItem value="account">account</SelectItem> | |
| 100 | + <SelectItem value="workspace">workspace</SelectItem> | |
| 101 | + <SelectItem value="organization">organization</SelectItem> | |
| 102 | + </SelectContent> | |
| 103 | + </Select> | |
| 93 | 104 | </label> |
| 94 | − <label className="flex flex-col gap-1 text-[10px] text-muted-foreground"> | |
| 105 | + <label className="flex flex-col gap-1 text-xs text-muted-foreground"> | |
| 95 | 106 | Durability |
| 96 | − <select | |
| 97 | − className="h-7 rounded-md border border-border/60 bg-background px-2 text-[11px] text-foreground" | |
| 107 | + <Select | |
| 98 | 108 | value={durability} |
| 99 | − onChange={(event) => onDurabilityChange(event.target.value as StorageDurability)} | |
| 109 | + onValueChange={(value) => onDurabilityChange(value as StorageDurability)} | |
| 100 | 110 | > |
| 101 | − <option value="ephemeral">ephemeral</option> | |
| 102 | − <option value="durable">durable</option> | |
| 103 | − </select> | |
| 111 | + <SelectTrigger className="h-8 w-full"> | |
| 112 | + <SelectValue /> | |
| 113 | + </SelectTrigger> | |
| 114 | + <SelectContent> | |
| 115 | + <SelectItem value="ephemeral">ephemeral</SelectItem> | |
| 116 | + <SelectItem value="durable">durable</SelectItem> | |
| 117 | + </SelectContent> | |
| 118 | + </Select> | |
| 104 | 119 | </label> |
| 105 | 120 | </div> |
| 106 | 121 | <Input |
| 107 | 122 | value={purpose} |
| 108 | 123 | onChange={(event) => onPurposeChange(event.target.value)} |
| 109 | 124 | placeholder="Purpose (optional)" |
| 110 | − className="h-7 rounded-md text-[11px]" | |
| 125 | + className="h-8 rounded-md" | |
| 111 | 126 | /> |
| 112 | 127 | <div className="flex items-end gap-2"> |
| 113 | 128 | <Input |
| @@ -115,9 +130,9 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 1 unmodified line | ||
| 115 | 130 | onChange={(event) => onTtlHoursChange(event.target.value)} |
| 116 | 131 | disabled={durability !== "ephemeral"} |
| 117 | 132 | placeholder="TTL (hours)" |
| 118 | − className="h-7 flex-1 rounded-md text-[11px]" | |
| 133 | + className="h-8 flex-1 rounded-md" | |
| 119 | 134 | /> |
| 120 | − <Button size="sm" className="h-7 rounded-md px-3 text-[10px]" disabled={creating} onClick={onCreate}> | |
| 135 | + <Button size="sm" className="h-8 rounded-md px-3 text-xs" disabled={creating} onClick={onCreate}> | |
| 121 | 136 | Create |
| 122 | 137 | </Button> |
| 123 | 138 | </div> |
| @@ -127,9 +142,15 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 3 unmodified lines | ||
| 127 | 142 | |
| 128 | 143 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 129 | 144 | {loading ? ( |
| 130 | − <div className="space-y-1 p-2"> | |
| 131 | − {Array.from({ length: 4 }).map((_, i) => ( | |
| 132 | − <Skeleton key={i} className="h-14 rounded-md" /> | |
| 145 | + <div className="space-y-1 p-1.5"> | |
| 146 | + {Array.from({ length: 5 }).map((_, index) => ( | |
| 147 | + <div key={index} className="flex items-start gap-2.5 rounded-md px-2.5 py-2"> | |
| 148 | + <Skeleton className="mt-0.5 h-6 w-6 shrink-0 rounded-md" /> | |
| 149 | + <div className="min-w-0 flex-1 space-y-1.5"> | |
| 150 | + <Skeleton className="h-3 w-24 rounded" /> | |
| 151 | + <Skeleton className="h-2.5 w-36 rounded" /> | |
| 152 | + </div> | |
| 153 | + </div> | |
| 133 | 154 | ))} |
| 134 | 155 | </div> |
| 135 | 156 | ) : visibleInstances.length === 0 ? ( |
| @@ -139,7 +160,7 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 3 unmodified lines | ||
| 139 | 160 | </div> |
| 140 | 161 | <div className="text-center"> |
| 141 | 162 | <p className="text-xs font-medium text-muted-foreground">No instances</p> |
| 142 | − <p className="mt-0.5 text-[10px] text-muted-foreground/60">Create one to get started</p> | |
| 163 | + <p className="mt-0.5 text-xs text-muted-foreground/60">Create one to get started</p> | |
| 143 | 164 | </div> |
| 144 | 165 | </div> |
| 145 | 166 | ) : ( |
| @@ -171,21 +192,21 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 25 unmodified lines | ||
| 171 | 192 | </div> |
| 172 | 193 | <div className="min-w-0 flex-1"> |
| 173 | 194 | <p className={cn( |
| 174 | − "truncate text-[11px] font-medium", | |
| 195 | + "truncate text-xs font-medium", | |
| 175 | 196 | active ? "text-foreground" : "text-foreground/80", |
| 176 | 197 | )}> |
| 177 | 198 | {instance.purpose || instance.id.slice(0, 12)} |
| 178 | 199 | </p> |
| 179 | 200 | <div className="mt-0.5 flex items-center gap-1.5"> |
| 180 | − <span className={cn("text-[9px] font-medium uppercase tracking-wider", scopeColor(instance.scopeType))}> | |
| 201 | + <span className={cn("text-xs font-medium", scopeColor(instance.scopeType))}> | |
| 181 | 202 | {instance.scopeType} |
| 182 | 203 | </span> |
| 183 | − <span className="text-[9px] text-muted-foreground/40">/</span> | |
| 184 | − <span className="text-[9px] text-muted-foreground"> | |
| 204 | + <span className="text-xs text-muted-foreground/40">/</span> | |
| 205 | + <span className="text-xs text-muted-foreground"> | |
| 185 | 206 | {prettyBytes(instance.sizeBytes)} |
| 186 | 207 | </span> |
| 187 | − <span className="text-[9px] text-muted-foreground/40">/</span> | |
| 188 | − <span className="text-[9px] text-muted-foreground"> | |
| 208 | + <span className="text-xs text-muted-foreground/40">/</span> | |
| 209 | + <span className="text-xs text-muted-foreground"> | |
| 189 | 210 | {relativeTime(instance.lastSeenAt)} |
| 190 | 211 | </span> |
| 191 | 212 | </div> |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { Play } from "lucide-react"; |
| 2 | 2 | import { Button } from "@/components/ui/button"; |
| 3 | 3 | import { Input } from "@/components/ui/input"; |
| 4 | +import { Textarea } from "@/components/ui/textarea"; | |
| 4 | 5 | import { TabsContent } from "@/components/ui/tabs"; |
| 5 | 6 | import { cn } from "@/lib/utils"; |
| 6 | 7 | import { sqlCellText, truncateText, type StorageSqlObject, type StorageSqlResult } from "./storage_panel_shared"; |
| @@ -67,21 +68,21 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 60 unmodified lines | ||
| 67 | 68 | |
| 68 | 69 | return ( |
| 69 | 70 | <TabsContent value="sql" className="flex-1 min-h-0 overflow-hidden"> |
| 70 | − <div className="flex h-full min-h-0 flex-col"> | |
| 71 | − {visibleSqlObjects.length > 0 && ( | |
| 72 | − <div className="flex items-center gap-1.5 border-b border-border/30 bg-muted/15 px-4 py-1.5 overflow-x-auto shrink-0"> | |
| 73 | − <span className="shrink-0 text-[9px] font-medium text-muted-foreground/60 uppercase tracking-wider mr-0.5">Tables</span> | |
| 74 | − {visibleSqlObjects.map((entry) => ( | |
| 75 | − <button | |
| 76 | − key={entry.name} | |
| 77 | − type="button" | |
| 78 | − className={cn( | |
| 79 | − "shrink-0 rounded-md px-2 py-0.5 text-[10px] font-medium transition-colors", | |
| 80 | − selectedSqlObjectName === entry.name | |
| 81 | − ? "bg-primary/15 text-primary ring-1 ring-primary/20" | |
| 82 | − : "text-foreground/60 hover:bg-accent/50 hover:text-foreground", | |
| 83 | − )} | |
| 84 | − onClick={() => onOpenSqlObject(entry.name)} | |
| 71 | + <div className="flex h-full min-h-0 flex-col"> | |
| 72 | + {visibleSqlObjects.length > 0 && ( | |
| 73 | + <div className="flex items-center gap-1.5 border-b border-border/30 bg-muted/15 px-4 py-1.5 overflow-x-auto shrink-0"> | |
| 74 | + <span className="mr-0.5 shrink-0 text-xs font-medium text-muted-foreground/70">Tables</span> | |
| 75 | + {visibleSqlObjects.map((entry) => ( | |
| 76 | + <button | |
| 77 | + key={entry.name} | |
| 78 | + type="button" | |
| 79 | + className={cn( | |
| 80 | + "shrink-0 rounded-md px-2 py-0.5 text-xs font-medium transition-colors", | |
| 81 | + selectedSqlObjectName === entry.name | |
| 82 | + ? "bg-primary/15 text-primary ring-1 ring-primary/20" | |
| 83 | + : "text-foreground/60 hover:bg-accent/50 hover:text-foreground", | |
| 84 | + )} | |
| 85 | + onClick={() => onOpenSqlObject(entry.name)} | |
| 85 | 86 | > |
| 86 | 87 | {entry.name} |
| 87 | 88 | </button> |
| @@ -90,7 +91,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 2 unmodified lines | ||
| 90 | 91 | <Button |
| 91 | 92 | variant="ghost" |
| 92 | 93 | size="sm" |
| 93 | − className="h-5 rounded px-1.5 text-[9px] text-muted-foreground/50" | |
| 94 | + className="h-5 rounded px-1.5 text-xs text-muted-foreground/60" | |
| 94 | 95 | onClick={onToggleShowInternalObjects} |
| 95 | 96 | > |
| 96 | 97 | {sqlShowInternalObjects ? "Hide sys" : "Show sys"} |
| @@ -98,7 +99,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 1 unmodified line | ||
| 98 | 99 | <Button |
| 99 | 100 | variant="ghost" |
| 100 | 101 | size="sm" |
| 101 | − className="h-5 rounded px-1.5 text-[9px] text-muted-foreground/50" | |
| 102 | + className="h-5 rounded px-1.5 text-xs text-muted-foreground/60" | |
| 102 | 103 | onClick={onRefreshSqlObjects} |
| 103 | 104 | disabled={sqlObjectsLoading} |
| 104 | 105 | > |
| @@ -115,7 +116,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 10 unmodified lines | ||
| 115 | 116 | )} |
| 116 | 117 | |
| 117 | 118 | <div className="shrink-0 border-b border-border/30 bg-muted/20 p-3"> |
| 118 | − <textarea | |
| 119 | + <Textarea | |
| 119 | 120 | value={sqlText} |
| 120 | 121 | onChange={(event) => { |
| 121 | 122 | onSqlTextChange(event.target.value); |
| @@ -126,45 +127,45 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 4 unmodified lines | ||
| 126 | 127 | onRunSql(); |
| 127 | 128 | } |
| 128 | 129 | }} |
| 129 | − className="min-h-[3.5rem] max-h-32 w-full resize-y rounded-md border border-border/40 bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-primary/30" | |
| 130 | + className="min-h-[3.5rem] max-h-32 resize-y font-mono leading-relaxed placeholder:text-muted-foreground/40" | |
| 130 | 131 | placeholder="SELECT * FROM ..." |
| 131 | 132 | /> |
| 132 | 133 | <div className="mt-2 flex flex-wrap items-center gap-1.5"> |
| 133 | 134 | <Button |
| 134 | 135 | size="sm" |
| 135 | − className="h-6 rounded-md px-2.5 text-[10px]" | |
| 136 | + className="h-6 rounded-md px-2.5 text-xs" | |
| 136 | 137 | disabled={sqlLoading || !canInspect} |
| 137 | 138 | onClick={onRunSql} |
| 138 | 139 | > |
| 139 | 140 | <Play className="mr-1 h-2.5 w-2.5" /> Run |
| 140 | 141 | </Button> |
| 141 | 142 | <div className="h-4 w-px bg-border/40 mx-0.5" /> |
| 142 | − <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={onRunUserTables}> | |
| 143 | + <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-xs text-muted-foreground" onClick={onRunUserTables}> | |
| 143 | 144 | User tables |
| 144 | 145 | </Button> |
| 145 | − <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={onRunAllObjects}> | |
| 146 | + <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-xs text-muted-foreground" onClick={onRunAllObjects}> | |
| 146 | 147 | All objects |
| 147 | 148 | </Button> |
| 148 | − <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={onRunKvSchema}> | |
| 149 | + <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-xs text-muted-foreground" onClick={onRunKvSchema}> | |
| 149 | 150 | KV schema |
| 150 | 151 | </Button> |
| 151 | − <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={onRunKvData}> | |
| 152 | + <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-xs text-muted-foreground" onClick={onRunKvData}> | |
| 152 | 153 | KV data |
| 153 | 154 | </Button> |
| 154 | − <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={onRunFsEntries}> | |
| 155 | + <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-xs text-muted-foreground" onClick={onRunFsEntries}> | |
| 155 | 156 | FS entries |
| 156 | 157 | </Button> |
| 157 | 158 | <div className="ml-auto flex items-center gap-1.5"> |
| 158 | − <span className="text-[9px] text-muted-foreground/50">Limit</span> | |
| 159 | + <span className="text-xs text-muted-foreground/60">Limit</span> | |
| 159 | 160 | <Input |
| 160 | 161 | value={sqlMaxRows} |
| 161 | 162 | onChange={(event) => onSqlMaxRowsChange(event.target.value)} |
| 162 | − className="h-6 w-14 rounded-md text-center text-[10px]" | |
| 163 | + className="h-6 w-14 rounded-md text-center" | |
| 163 | 164 | /> |
| 164 | 165 | </div> |
| 165 | 166 | </div> |
| 166 | − <p className="mt-1.5 text-[9px] text-muted-foreground/40"> | |
| 167 | − Press <kbd className="rounded border border-border/40 bg-muted/50 px-1 py-0.5 text-[8px] font-mono">Cmd+Enter</kbd> to run | |
| 167 | + <p className="mt-1.5 text-xs text-muted-foreground/50"> | |
| 168 | + Press <kbd className="rounded border border-border/40 bg-muted/50 px-1 py-0.5 text-[11px] font-mono">Cmd+Enter</kbd> to run | |
| 168 | 169 | </p> |
| 169 | 170 | </div> |
| 170 | 171 | |
| @@ -182,7 +183,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 11 unmodified lines | ||
| 182 | 183 | ) : sqlResult ? ( |
| 183 | 184 | <> |
| 184 | 185 | <div className="flex items-center justify-between border-b border-border/20 px-4 py-1.5 bg-muted/10"> |
| 185 | − <span className="text-[10px] text-muted-foreground"> | |
| 186 | + <span className="text-xs text-muted-foreground"> | |
| 186 | 187 | {sqlResult.rowCount} row{sqlResult.rowCount === 1 ? "" : "s"} |
| 187 | 188 | {typeof sqlResult.changes === "number" ? ` · ${sqlResult.changes} change${sqlResult.changes === 1 ? "" : "s"}` : ""} |
| 188 | 189 | {sqlColumns.length > 0 ? ` · ${sqlColumns.length} col${sqlColumns.length === 1 ? "" : "s"}` : ""} |
| @@ -191,7 +192,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 2 unmodified lines | ||
| 191 | 192 | <Button |
| 192 | 193 | variant={sqlViewMode === "table" ? "default" : "ghost"} |
| 193 | 194 | size="sm" |
| 194 | − className="h-5 rounded-md px-2 text-[9px]" | |
| 195 | + className="h-5 rounded-md px-2 text-xs" | |
| 195 | 196 | onClick={() => onSqlViewModeChange("table")} |
| 196 | 197 | > |
| 197 | 198 | Table |
| @@ -199,7 +200,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 1 unmodified line | ||
| 199 | 200 | <Button |
| 200 | 201 | variant={sqlViewMode === "json" ? "default" : "ghost"} |
| 201 | 202 | size="sm" |
| 202 | − className="h-5 rounded-md px-2 text-[9px]" | |
| 203 | + className="h-5 rounded-md px-2 text-xs" | |
| 203 | 204 | onClick={() => onSqlViewModeChange("json")} |
| 204 | 205 | > |
| 205 | 206 | JSON |
| @@ -217,14 +218,14 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 11 unmodified lines | ||
| 217 | 218 | <p className="text-xs text-muted-foreground/60">Query returned no rows</p> |
| 218 | 219 | </div> |
| 219 | 220 | ) : ( |
| 220 | − <table className="min-w-full border-collapse text-[11px]"> | |
| 221 | + <table className="min-w-full border-collapse text-xs"> | |
| 221 | 222 | <thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur-sm"> |
| 222 | 223 | <tr> |
| 223 | − <th className="border-b border-r border-border/30 px-3 py-1.5 text-left text-[10px] font-medium text-muted-foreground w-10">#</th> | |
| 224 | + <th className="w-10 border-b border-r border-border/30 px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">#</th> | |
| 224 | 225 | {sqlColumns.map((column) => ( |
| 225 | 226 | <th |
| 226 | 227 | key={column} |
| 227 | − className="border-b border-r border-border/30 px-3 py-1.5 text-left text-[10px] font-medium text-muted-foreground last:border-r-0" | |
| 228 | + className="border-b border-r border-border/30 px-3 py-1.5 text-left text-xs font-medium text-muted-foreground last:border-r-0" | |
| 228 | 229 | > |
| 229 | 230 | {column} |
| 230 | 231 | </th> |
| @@ -8,7 +8,6 @@ import { ToolExplorer } from "@/components/tools/explorer"; · 7 unmodified lines | ||
| 8 | 8 | import { TaskComposer } from "@/components/tasks/task-composer"; |
| 9 | 9 | |
| 10 | 10 | import { CredentialsPanel } from "@/components/tools/credentials"; |
| 11 | −import { ConnectionFormDialog } from "@/components/tools/connection/form-dialog"; | |
| 12 | 11 | import { PoliciesPanel } from "@/components/tools/policies"; |
| 13 | 12 | import { StoragePanel } from "@/components/tools/storage"; |
| 14 | 13 | import { useSession } from "@/lib/session-context"; |
| @@ -34,6 +33,13 @@ import { toolsCatalogQueryParsers } from "@/lib/url-state/tools"; · 19 unmodified lines | ||
| 34 | 33 | |
| 35 | 34 | type ToolsTab = "catalog" | "connections" | "policies" | "storage" | "editor"; |
| 36 | 35 | const INVENTORY_REGENERATION_TOAST_ID = "tool-inventory-regeneration"; |
| 36 | +const TAB_USES_OWN_LOADING_STATE: Record<ToolsTab, boolean> = { | |
| 37 | + catalog: true, | |
| 38 | + connections: true, | |
| 39 | + policies: true, | |
| 40 | + storage: true, | |
| 41 | + editor: true, | |
| 42 | +}; | |
| 37 | 43 | |
| 38 | 44 | function parseToolsTab(pathname: string): ToolsTab { |
| 39 | 45 | const segments = pathname.split("/").filter(Boolean); |
| @@ -76,17 +82,10 @@ export function ToolsView() { · 36 unmodified lines | ||
| 76 | 82 | const catalogFilterValue = catalogQueryState.approval as FilterApproval; |
| 77 | 83 | const catalogSourceValue = useMemo(() => mapEmptyQueryValueToNull(catalogQueryState.source), [catalogQueryState.source]); |
| 78 | 84 | const catalogActiveToolPath = mapEmptyQueryValueToNull(catalogQueryState.tool); |
| 79 | − const catalogSelectedToolPaths = useMemo( | |
| 80 | − () => catalogQueryState.selected ?? [], | |
| 81 | − [catalogQueryState.selected], | |
| 82 | − ); | |
| 83 | 85 | const catalogFocusedSourceName = useMemo( |
| 84 | 86 | () => mapEmptyQueryValueToNull(catalogQueryState.sourcePanel), |
| 85 | 87 | [catalogQueryState.sourcePanel], |
| 86 | 88 | ); |
| 87 | − const [connectionDialogOpen, setConnectionDialogOpen] = useState(false); | |
| 88 | − const [connectionDialogEditing, setConnectionDialogEditing] = useState<CredentialRecord | null>(null); | |
| 89 | − const [connectionDialogSourceKey, setConnectionDialogSourceKey] = useState<string | null>(null); | |
| 90 | 89 | const [storageMutationBusyId, setStorageMutationBusyId] = useState<string | undefined>(undefined); |
| 91 | 90 | const [storageCreateBusy, setStorageCreateBusy] = useState(false); |
| 92 | 91 | const [regenerationInFlight, setRegenerationInFlight] = useState(false); |
| @@ -207,6 +206,11 @@ export function ToolsView() { · 114 unmodified lines | ||
| 207 | 206 | const credentialsLoading = !!context && credentials === undefined; |
| 208 | 207 | const storageItems = storageInstances ?? []; |
| 209 | 208 | const storageLoading = !!context && storageInstances === undefined; |
| 209 | + const catalogPanelLoading = sessionLoading || loadingTools; | |
| 210 | + const connectionsPanelLoading = sessionLoading || credentialsLoading || sourcesLoading; | |
| 211 | + const policiesPanelLoading = sessionLoading || loadingTools; | |
| 212 | + const storagePanelLoading = sessionLoading || storageLoading; | |
| 213 | + const shouldRenderShellSkeleton = sessionLoading && !TAB_USES_OWN_LOADING_STATE[activeTab]; | |
| 210 | 214 | |
| 211 | 215 | const globalWarnings = useMemo( |
| 212 | 216 | () => warnings.filter((warning) => !parseWarningSourceName(warning)), |
| @@ -330,14 +334,6 @@ export function ToolsView() { · 117 unmodified lines | ||
| 330 | 334 | }); |
| 331 | 335 | }, [setCatalogQueryState]); |
| 332 | 336 | |
| 333 | − const setCatalogSelectedToolPaths = useCallback((toolPaths: string[]) => { | |
| 334 | − void setCatalogQueryState({ | |
| 335 | − selected: toolPaths, | |
| 336 | − }, { | |
| 337 | − history: "replace", | |
| 338 | − }); | |
| 339 | − }, [setCatalogQueryState]); | |
| 340 | − | |
| 341 | 337 | const setCatalogFocusedSourceName = useCallback((sourceName: string | null) => { |
| 342 | 338 | void setCatalogQueryState({ |
| 343 | 339 | sourcePanel: sourceName ?? "", |
| @@ -367,26 +363,6 @@ export function ToolsView() { · 23 unmodified lines | ||
| 367 | 363 | setRegenerationInFlight(false); |
| 368 | 364 | } |
| 369 | 365 | }, [context, rebuildInventoryNow, regenerationInFlight, showRegenerationToast]); |
| 370 | − const openConnectionCreate = (sourceKey?: string) => { | |
| 371 | − setConnectionDialogEditing(null); | |
| 372 | − setConnectionDialogSourceKey(sourceKey ?? null); | |
| 373 | − setConnectionDialogOpen(true); | |
| 374 | − }; | |
| 375 | − | |
| 376 | − const openConnectionEdit = (credential: CredentialRecord) => { | |
| 377 | − setConnectionDialogEditing(credential); | |
| 378 | − setConnectionDialogSourceKey(null); | |
| 379 | − setConnectionDialogOpen(true); | |
| 380 | − }; | |
| 381 | − | |
| 382 | − const handleConnectionDialogOpenChange = (open: boolean) => { | |
| 383 | − setConnectionDialogOpen(open); | |
| 384 | − if (!open) { | |
| 385 | − setConnectionDialogEditing(null); | |
| 386 | − setConnectionDialogSourceKey(null); | |
| 387 | − } | |
| 388 | − }; | |
| 389 | − | |
| 390 | 366 | const handleCreateStorageInstance = useCallback(async (args: { |
| 391 | 367 | scopeType: StorageScopeType; |
| 392 | 368 | durability: StorageDurability; |
| @@ -455,7 +431,7 @@ export function ToolsView() { · 62 unmodified lines | ||
| 455 | 431 | } |
| 456 | 432 | }, [context, deleteStorageInstance, storageMutationBusyId]); |
| 457 | 433 | |
| 458 | − if (sessionLoading) { | |
| 434 | + if (shouldRenderShellSkeleton) { | |
| 459 | 435 | return ( |
| 460 | 436 | <div className="flex h-full min-h-0 flex-col"> |
| 461 | 437 | <div className="flex flex-1 min-h-0 rounded-none border border-border/50 p-4"> |
| @@ -487,7 +463,7 @@ export function ToolsView() { · 25 unmodified lines | ||
| 487 | 463 | return ( |
| 488 | 464 | <div className="flex h-full min-h-0 flex-col"> |
| 489 | 465 | {activeTab === "editor" ? ( |
| 490 | − <div className="flex-1 min-h-0 overflow-y-auto p-4 md:p-6 lg:p-8"> | |
| 466 | + <div className="flex-1 min-h-0 overflow-hidden p-0"> | |
| 491 | 467 | <TaskComposer /> |
| 492 | 468 | </div> |
| 493 | 469 | ) : null} |
| @@ -506,7 +482,7 @@ export function ToolsView() { · 12 unmodified lines | ||
| 506 | 482 | sourceHasMoreTools={sourceHasMoreTools} |
| 507 | 483 | sourceLoadingMoreTools={sourceLoadingMoreTools} |
| 508 | 484 | onLoadMoreToolsForSource={loadMoreToolsForSource} |
| 509 | − loading={loadingTools} | |
| 485 | + loading={catalogPanelLoading} | |
| 510 | 486 | sourceDialogMeta={sourceDialogMeta} |
| 511 | 487 | sourceAuthProfiles={sourceAuthProfiles} |
| 512 | 488 | existingSourceNames={existingSourceNames} |
| @@ -517,12 +493,10 @@ export function ToolsView() { · 4 unmodified lines | ||
| 517 | 493 | searchValue={catalogSearchValue} |
| 518 | 494 | filterApprovalValue={catalogFilterValue} |
| 519 | 495 | focusedToolPathValue={catalogActiveToolPath} |
| 520 | − selectedToolPathsValue={catalogSelectedToolPaths} | |
| 521 | 496 | focusedSourceNameValue={catalogFocusedSourceName} |
| 522 | 497 | onSearchValueChange={setCatalogSearch} |
| 523 | 498 | onFilterApprovalValueChange={setCatalogApprovalFilter} |
| 524 | 499 | onFocusedToolPathChange={setCatalogActiveToolPath} |
| 525 | − onSelectedToolPathsChange={setCatalogSelectedToolPaths} | |
| 526 | 500 | onFocusedSourceNameChange={setCatalogFocusedSourceName} |
| 527 | 501 | onRegenerate={handleRegenerateInventory} |
| 528 | 502 | inventoryState={inventoryStatus?.state} |
| @@ -537,22 +511,22 @@ export function ToolsView() { · 8 unmodified lines | ||
| 537 | 511 | ) : null} |
| 538 | 512 | |
| 539 | 513 | {activeTab === "connections" ? ( |
| 540 | − <div className="flex-1 min-h-0 overflow-y-auto p-4 md:p-6 lg:p-8"> | |
| 514 | + <div className="flex-1 min-h-0 overflow-hidden p-0"> | |
| 541 | 515 | <CredentialsPanel |
| 542 | 516 | sources={sourceItems} |
| 543 | 517 | credentials={credentialItems} |
| 544 | − loading={credentialsLoading || sourcesLoading} | |
| 545 | − onCreateConnection={openConnectionCreate} | |
| 546 | − onEditConnection={openConnectionEdit} | |
| 518 | + loading={connectionsPanelLoading} | |
| 519 | + sourceAuthProfiles={sourceAuthProfiles} | |
| 520 | + loadingSourceNames={visibleLoadingSources} | |
| 547 | 521 | /> |
| 548 | 522 | </div> |
| 549 | 523 | ) : null} |
| 550 | 524 | |
| 551 | 525 | {activeTab === "policies" ? ( |
| 552 | − <div className="flex-1 min-h-0 overflow-y-auto p-4 md:p-6 lg:p-8"> | |
| 526 | + <div className="flex-1 min-h-0 overflow-hidden p-0"> | |
| 553 | 527 | <PoliciesPanel |
| 554 | 528 | tools={visibleTools} |
| 555 | − loadingTools={loadingTools} | |
| 529 | + loadingTools={policiesPanelLoading} | |
| 556 | 530 | /> |
| 557 | 531 | </div> |
| 558 | 532 | ) : null} |
| @@ -563,7 +537,7 @@ export function ToolsView() { · 4 unmodified lines | ||
| 563 | 537 | workspaceId={context?.workspaceId} |
| 564 | 538 | sessionId={context?.sessionId} |
| 565 | 539 | instances={storageItems} |
| 566 | − loading={storageLoading} | |
| 540 | + loading={storagePanelLoading} | |
| 567 | 541 | creating={storageCreateBusy} |
| 568 | 542 | busyInstanceId={storageMutationBusyId} |
| 569 | 543 | onCreate={handleCreateStorageInstance} |
| @@ -573,16 +547,6 @@ export function ToolsView() { · 3 unmodified lines | ||
| 573 | 547 | </div> |
| 574 | 548 | ) : null} |
| 575 | 549 | |
| 576 | − <ConnectionFormDialog | |
| 577 | − open={connectionDialogOpen} | |
| 578 | − onOpenChange={handleConnectionDialogOpenChange} | |
| 579 | − editing={connectionDialogEditing} | |
| 580 | − initialSourceKey={connectionDialogSourceKey} | |
| 581 | − sources={sourceItems} | |
| 582 | − credentials={credentialItems} | |
| 583 | − sourceAuthProfiles={sourceAuthProfiles} | |
| 584 | − loadingSourceNames={visibleLoadingSources} | |
| 585 | − /> | |
| 586 | 550 | </div> |
| 587 | 551 | ); |
| 588 | 552 | } |
| @@ -0,0 +1,20 @@ | ||
| 1 | +import * as React from "react" | |
| 2 | + | |
| 3 | +import { cn } from "@/lib/utils" | |
| 4 | + | |
| 5 | +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { | |
| 6 | + return ( | |
| 7 | + <textarea | |
| 8 | + data-slot="textarea" | |
| 9 | + className={cn( | |
| 10 | + "border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 flex w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", | |
| 11 | + "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", | |
| 12 | + "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", | |
| 13 | + className | |
| 14 | + )} | |
| 15 | + {...props} | |
| 16 | + /> | |
| 17 | + ) | |
| 18 | +} | |
| 19 | + | |
| 20 | +export { Textarea } | |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | type SourceAuthType, |
| 7 | 7 | } from "@/lib/tools/source-helpers"; |
| 8 | 8 | import type { |
| 9 | + CredentialAdditionalHeader, | |
| 9 | 10 | CredentialScope, |
| 10 | 11 | SourceAuthProfile, |
| 11 | 12 | ToolSourceScopeType, |
| @@ -72,7 +73,12 @@ export function connectionDisplayName( · 60 unmodified lines | ||
| 72 | 73 | return `${base} ${ownerScopeLabel(connection.scopeType)}`; |
| 73 | 74 | } |
| 74 | 75 | |
| 75 | −export function parseHeaderOverrides(text: string): { value?: Record<string, string>; error?: string } { | |
| 76 | +type AdditionalHeadersParseResult = { | |
| 77 | + value?: Record<string, string>; | |
| 78 | + error?: string; | |
| 79 | +}; | |
| 80 | + | |
| 81 | +export function parseAdditionalHeadersText(text: string): AdditionalHeadersParseResult { | |
| 76 | 82 | const lines = text |
| 77 | 83 | .split("\n") |
| 78 | 84 | .map((line) => line.trim()) |
| @@ -98,12 +104,12 @@ export function parseHeaderOverrides(text: string): { value?: Record<string, str · 19 unmodified lines | ||
| 98 | 104 | return { value: headers }; |
| 99 | 105 | } |
| 100 | 106 | |
| 101 | −export type HeaderOverrideEntry = { | |
| 107 | +export type AdditionalHeaderEntry = { | |
| 102 | 108 | key: string; |
| 103 | 109 | value: string; |
| 104 | 110 | }; |
| 105 | 111 | |
| 106 | −export function parseHeaderOverrideEntries(text: string): HeaderOverrideEntry[] { | |
| 112 | +export function parseAdditionalHeaderEntries(text: string): AdditionalHeaderEntry[] { | |
| 107 | 113 | const rows = text |
| 108 | 114 | .split("\n") |
| 109 | 115 | .map((line) => line.trim()) |
| @@ -126,7 +132,7 @@ export function parseHeaderOverrideEntries(text: string): HeaderOverrideEntry[] · 16 unmodified lines | ||
| 126 | 132 | }); |
| 127 | 133 | } |
| 128 | 134 | |
| 129 | −export function parseHeaderOverrideRows(entries: HeaderOverrideEntry[]): { value?: Record<string, string>; error?: string } { | |
| 135 | +export function parseAdditionalHeaderRows(entries: AdditionalHeaderEntry[]): AdditionalHeadersParseResult { | |
| 130 | 136 | const headers: Record<string, string> = {}; |
| 131 | 137 | |
| 132 | 138 | for (let index = 0; index < entries.length; index += 1) { |
| @@ -149,7 +155,7 @@ export function parseHeaderOverrideRows(entries: HeaderOverrideEntry[]): { value · 16 unmodified lines | ||
| 149 | 155 | return { value: headers }; |
| 150 | 156 | } |
| 151 | 157 | |
| 152 | −export function serializeHeaderOverrideRows(entries: HeaderOverrideEntry[]): string { | |
| 158 | +export function serializeAdditionalHeaderRows(entries: AdditionalHeaderEntry[]): string { | |
| 153 | 159 | return entries |
| 154 | 160 | .map((entry) => ({ |
| 155 | 161 | key: entry.key.trim(), |
| @@ -160,9 +166,13 @@ export function serializeHeaderOverrideRows(entries: HeaderOverrideEntry[]): str · 4 unmodified lines | ||
| 160 | 166 | .join("\n"); |
| 161 | 167 | } |
| 162 | 168 | |
| 163 | −export function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string { | |
| 164 | − const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {}; | |
| 165 | − return Object.entries(headers) | |
| 166 | − .map(([key, value]) => `${key}: ${String(value)}`) | |
| 169 | +export function formatAdditionalHeaders(headers: CredentialAdditionalHeader[] | undefined): string { | |
| 170 | + return (headers ?? []) | |
| 171 | + .map((header) => ({ | |
| 172 | + key: header.name.trim(), | |
| 173 | + value: header.value.trim(), | |
| 174 | + })) | |
| 175 | + .filter((entry) => entry.key.length > 0 && entry.value.length > 0) | |
| 176 | + .map((entry) => `${entry.key}: ${entry.value}`) | |
| 167 | 177 | .join("\n"); |
| 168 | 178 | } |
| @@ -1,13 +1,15 @@ | ||
| 1 | 1 | import { parse as parseDomain } from "tldts"; |
| 2 | 2 | import type { |
| 3 | + CredentialScope, | |
| 3 | 4 | CredentialRecord, |
| 4 | 5 | OpenApiSourceQuality, |
| 5 | 6 | SourceAuthProfile, |
| 7 | + SourceAuthType as SharedSourceAuthType, | |
| 6 | 8 | ToolSourceRecord, |
| 7 | 9 | } from "@/lib/types"; |
| 8 | 10 | |
| 9 | −export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed"; | |
| 10 | −export type SourceAuthMode = "workspace" | "account" | "organization"; | |
| 11 | +export type SourceAuthType = SharedSourceAuthType; | |
| 12 | +export type SourceAuthMode = CredentialScope; | |
| 11 | 13 | |
| 12 | 14 | const RAW_HOSTS = new Set([ |
| 13 | 15 | "raw.githubusercontent.com", |
| @@ -1,15 +1,19 @@ | ||
| 1 | 1 | import type { Id } from "@executor/database/convex/_generated/dataModel"; |
| 2 | 2 | import type { |
| 3 | 3 | ApprovalStatus as CoreApprovalStatus, |
| 4 | + ArgumentCondition as CoreArgumentCondition, | |
| 4 | 5 | ArgumentConditionOperator as CoreArgumentConditionOperator, |
| 6 | + CredentialAdditionalHeader as CoreCredentialAdditionalHeader, | |
| 5 | 7 | CredentialProvider as CoreCredentialProvider, |
| 6 | 8 | CredentialScope as CoreCredentialScope, |
| 7 | 9 | CredentialScopeType as CoreCredentialScopeType, |
| 10 | + OpenApiSourceQuality as CoreOpenApiSourceQuality, | |
| 8 | 11 | PolicyApprovalMode as CorePolicyApprovalMode, |
| 9 | 12 | PolicyDecision as CorePolicyDecision, |
| 10 | 13 | PolicyEffect as CorePolicyEffect, |
| 11 | 14 | PolicyMatchType as CorePolicyMatchType, |
| 12 | 15 | PolicyScopeType as CorePolicyScopeType, |
| 16 | + SourceAuthProfile as CoreSourceAuthProfile, | |
| 13 | 17 | SourceAuthType as CoreSourceAuthType, |
| 14 | 18 | StorageDurability as CoreStorageDurability, |
| 15 | 19 | StorageInstanceStatus as CoreStorageInstanceStatus, |
| @@ -40,11 +44,8 @@ export type ToolRoleSelectorType = CoreToolRoleSelectorType; · 24 unmodified lines | ||
| 40 | 44 | export type ToolRoleBindingStatus = CoreToolRoleBindingStatus; |
| 41 | 45 | export type ArgumentConditionOperator = CoreArgumentConditionOperator; |
| 42 | 46 | |
| 43 | −export interface ArgumentCondition { | |
| 44 | − key: string; | |
| 45 | − operator: ArgumentConditionOperator; | |
| 46 | − value: string; | |
| 47 | −} | |
| 47 | +export type ArgumentCondition = CoreArgumentCondition; | |
| 48 | +export type CredentialAdditionalHeader = CoreCredentialAdditionalHeader; | |
| 48 | 49 | export type CredentialScope = CoreCredentialScope; |
| 49 | 50 | export type CredentialProvider = CoreCredentialProvider; |
| 50 | 51 | export type ToolSourceScopeType = CoreToolSourceScopeType; |
| @@ -58,12 +59,7 @@ export type StorageProvider = CoreStorageProvider; · 7 unmodified lines | ||
| 58 | 59 | |
| 59 | 60 | export type SourceAuthType = CoreSourceAuthType; |
| 60 | 61 | |
| 61 | −export interface SourceAuthProfile { | |
| 62 | − type: SourceAuthType; | |
| 63 | − mode?: CredentialScope; | |
| 64 | − header?: string; | |
| 65 | − inferred: boolean; | |
| 66 | −} | |
| 62 | +export type SourceAuthProfile = CoreSourceAuthProfile; | |
| 67 | 63 | |
| 68 | 64 | export interface TaskRecord { |
| 69 | 65 | id: string; |
| @@ -122,7 +118,7 @@ export interface CredentialRecord { · 52 unmodified lines | ||
| 122 | 118 | organizationId?: string; |
| 123 | 119 | workspaceId?: string; |
| 124 | 120 | sourceKey: string; |
| 125 | − overridesJson?: Record<string, unknown>; | |
| 121 | + additionalHeaders: CredentialAdditionalHeader[]; | |
| 126 | 122 | boundAuthFingerprint?: string; |
| 127 | 123 | provider: CredentialProvider; |
| 128 | 124 | secretJson: Record<string, unknown>; |
| @@ -191,17 +187,7 @@ export interface ToolDescriptor { · 62 unmodified lines | ||
| 191 | 187 | }; |
| 192 | 188 | } |
| 193 | 189 | |
| 194 | −export interface OpenApiSourceQuality { | |
| 195 | − sourceKey: string; | |
| 196 | − toolCount: number; | |
| 197 | − unknownArgsCount: number; | |
| 198 | − unknownReturnsCount: number; | |
| 199 | − partialUnknownArgsCount: number; | |
| 200 | − partialUnknownReturnsCount: number; | |
| 201 | − argsQuality: number; | |
| 202 | − returnsQuality: number; | |
| 203 | − overallQuality: number; | |
| 204 | −} | |
| 190 | +export type OpenApiSourceQuality = CoreOpenApiSourceQuality; | |
| 205 | 191 | |
| 206 | 192 | export interface AnonymousContext { |
| 207 | 193 | sessionId: string; |
| @@ -1,11 +1,9 @@ | ||
| 1 | 1 | import { |
| 2 | − parseAsNativeArrayOf, | |
| 3 | 2 | parseAsString, |
| 4 | 3 | parseAsStringLiteral, |
| 5 | 4 | } from "nuqs"; |
| 6 | 5 | import { |
| 7 | 6 | asTrimmedString, |
| 8 | − asTrimmedStringArray, | |
| 9 | 7 | } from "@/lib/url-state/shared"; |
| 10 | 8 | |
| 11 | 9 | export const toolsApprovalValues = ["all", "required", "auto"] as const; |
| @@ -16,7 +14,6 @@ export const toolsCatalogQueryParsers = { · 4 unmodified lines | ||
| 16 | 14 | q: parseAsString.withDefault(""), |
| 17 | 15 | approval: parseAsStringLiteral(toolsApprovalValues).withDefault("all"), |
| 18 | 16 | tool: parseAsString.withDefault(""), |
| 19 | − selected: parseAsNativeArrayOf(parseAsString), | |
| 20 | 17 | source: parseAsString.withDefault(""), |
| 21 | 18 | sourcePanel: parseAsString.withDefault(""), |
| 22 | 19 | }; |
| @@ -25,7 +22,6 @@ export type ToolsSearch = { · 2 unmodified lines | ||
| 25 | 22 | q?: string; |
| 26 | 23 | approval?: ToolsApproval; |
| 27 | 24 | tool?: string; |
| 28 | − selected?: string[]; | |
| 29 | 25 | source?: string; |
| 30 | 26 | sourcePanel?: string; |
| 31 | 27 | }; |
| @@ -42,7 +38,6 @@ export function normalizeToolsSearch(search: Record<string, unknown>): ToolsSear · 10 unmodified lines | ||
| 42 | 38 | const q = asTrimmedString(search.q); |
| 43 | 39 | const approval = normalizeToolsApproval(search.approval); |
| 44 | 40 | const tool = asTrimmedString(search.tool); |
| 45 | − const selected = asTrimmedStringArray(search.selected); | |
| 46 | 41 | const source = asTrimmedString(search.source); |
| 47 | 42 | const sourcePanel = asTrimmedString(search.sourcePanel); |
| 48 | 43 | |
| @@ -50,7 +45,6 @@ export function normalizeToolsSearch(search: Record<string, unknown>): ToolsSear · 1 unmodified line | ||
| 50 | 45 | ...(q ? { q } : {}), |
| 51 | 46 | ...(approval === "all" ? {} : { approval }), |
| 52 | 47 | ...(tool ? { tool } : {}), |
| 53 | − ...(selected ? { selected } : {}), | |
| 54 | 48 | ...(source ? { source } : {}), |
| 55 | 49 | ...(sourcePanel ? { sourcePanel } : {}), |
| 56 | 50 | }; |