forked from koshikraj/ottopus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathagents-panel.tsx
More file actions
177 lines (165 loc) · 6.76 KB
/
Copy pathagents-panel.tsx
File metadata and controls
177 lines (165 loc) · 6.76 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
'use client'
import { useState, type ReactNode } from 'react'
import { usePrivyAvailable } from '@/components/auth'
import { Otto } from '@/components/brand'
import { SkeletonShelf } from '@/components/motion/loaders'
import { Button, Chip, EmptyState, ErrorState } from '@/components/ui'
import type { AgentGrant } from '@/lib/api'
import { AgentList } from './agent-list'
import { ConnectAgentDialog } from './connect-agent-dialog'
import { useAgents } from './use-agents'
/**
* Settings' agent card, beside the wallets one.
*
* Split the same way WalletsPanel is, and for the same reason: Privy's hooks
* throw outside their provider, the provider does not mount without a valid app
* id, and a hook cannot be called conditionally.
*/
export function AgentsPanel() {
return usePrivyAvailable() ? (
<ConnectedPanel />
) : (
<Card>
<ErrorState
title="Sign-in isn’t configured"
description="Agent grants need Privy, and this deployment has no valid app id. Nothing is wrong with your account."
illustration={<Otto pose="ink" size={112} />}
/>
</Card>
)
}
/** Live means "may act right now": granted, and not revoked. */
const isLive = (agent: AgentGrant) => agent.revokedAt === null
function ConnectedPanel() {
const { state, revoke, refresh } = useAgents()
const [showRevoked, setShowRevoked] = useState(false)
const [connecting, setConnecting] = useState(false)
const agents = state.status === 'ready' ? state.agents : []
const live = agents.filter(isLive)
const revoked = agents.filter((agent) => !isLive(agent))
const connect = (
<Button variant="secondary" size="sm" onClick={() => setConnecting(true)}>
Connect agent
</Button>
)
return (
<Card count={state.status === 'ready' ? live.length : undefined} action={connect}>
{/* A failed read is not an empty list. Rendering "No agents yet" over a
request that never arrived tells someone their grants are gone. */}
{state.status === 'failed' ? (
<ErrorState
title="Can’t reach Ottopus right now"
description="This is our side, not yours. No grant was changed, revoked or created — we simply could not read the list."
illustration={<Otto pose="ink" size={112} />}
action={
<Button variant="secondary" size="sm" onClick={refresh}>
Try again
</Button>
}
/>
) : null}
{/* The count is live grants, so the empty state has to key off the same
thing. Listing revoked ones under "Connected agents 0" reads as a
contradiction, and someone whose only grants are revoked still needs
telling how to connect one. */}
{state.status === 'failed' ? null : state.status === 'loading' ? (
<SkeletonShelf rows={2} />
) : live.length === 0 ? (
<div className="px-[22px] py-7">
<EmptyState
title={revoked.length > 0 ? 'No agents connected' : 'No agents yet'}
description="Add the Ottopus MCP server to Claude or Codex, and the grant you approve will show up here."
illustration={<Otto pose="base" size={96} animated />}
action={connect}
/>
</div>
) : (
<AgentList agents={live} onRevoke={revoke} />
)}
{/* Kept, because a revoked grant is the record that an agent once had
access and no longer does — but folded away, since it accumulates one
row per revocation and none of them can do anything. */}
{revoked.length > 0 ? (
<div className="border-t border-[var(--ot-border)]">
<button
type="button"
aria-expanded={showRevoked}
onClick={() => setShowRevoked((open) => !open)}
className="flex w-full cursor-pointer items-center justify-between gap-3 px-[22px] py-3 text-[12.5px] text-[var(--ot-text-2)] transition-colors hover:text-[var(--ot-text)] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--ot-plan)]"
>
<span>
Previously connected{' '}
<span className="text-[var(--ot-text-3)]">{revoked.length}</span>
</span>
<span aria-hidden className="text-[var(--ot-text-3)]">
{showRevoked ? 'Hide' : 'Show'}
</span>
</button>
{showRevoked ? <AgentList agents={revoked} onRevoke={revoke} /> : null}
</div>
) : null}
<p className="border-t border-[var(--ot-border)] bg-[var(--ot-surface-2)] px-[22px] py-3.5 text-[12.5px] leading-[1.5] text-[var(--ot-text-2)]">
No grant can sign or move anything. Revoking one stops it preparing new requests
immediately.
</p>
<ConnectAgentDialog open={connecting} onClose={() => setConnecting(false)} />
</Card>
)
}
/** The P6 frame, matching the wallets card it sits beside. */
function Card({
children,
count,
action,
}: {
children: ReactNode
count?: number | undefined
action?: ReactNode
}) {
return (
<section className="overflow-hidden rounded-[18px] border border-[var(--ot-border)] bg-[var(--ot-card)]">
<div className="flex items-center justify-between gap-4 border-b border-[var(--ot-border)] px-[22px] py-4">
<h2 className="text-[15px] font-semibold">
Connected agents{' '}
{count !== undefined ? (
<span className="font-normal text-[var(--ot-text-3)]">{count}</span>
) : null}
</h2>
{action}
</div>
{children}
</section>
)
}
/** Exported for the list, which shows the same chip for each granted scope. */
export function ScopeChips({ agent }: { agent: AgentGrant }) {
const [expanded, setExpanded] = useState(false)
return (
<div className="flex flex-wrap items-center gap-1.5">
{agent.scopes.map((scope) => (
<Chip key={scope.scope} className="text-[11px]">
{scope.title}
</Chip>
))}
{agent.scopes.length > 0 ? (
<button
type="button"
onClick={() => setExpanded((open) => !open)}
className="cursor-pointer rounded-full px-1.5 py-0.5 text-[11px] text-[var(--ot-text-3)] transition-colors hover:text-[var(--ot-text)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ot-plan)]"
>
{expanded ? 'Less' : 'What this means'}
</button>
) : null}
{expanded ? (
<ul className="mt-1 flex w-full list-none flex-col gap-1 p-0">
{agent.scopes.map((scope) => (
<li key={scope.scope} className="text-[12px] leading-[1.45] text-[var(--ot-text-2)]">
<span className="font-medium text-[var(--ot-text)]">{scope.title}</span> —{' '}
{scope.detail}
</li>
))}
</ul>
) : null}
</div>
)
}