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