Skip to content

Commit d1b6c81

Browse files
committed
feat: mobile connect, agent approval, session presence, caffeinate
High-value additions inspired by Happy Coder patterns: - Mobile Connect: QR code panel for connecting Knot Code iOS to gateway (direct WebSocket, no relay server needed) - Agent Approval: notification cards for tool/file/command permissions with approve/deny from any device, haptic feedback on mobile - Session Presence: shows connected devices (compact avatars in status bar, full list in settings panel) - Caffeinate Toggle: Screen Wake Lock API to prevent sleep during agent work - Settings Panel: unified panel with Connect and General tabs - Status bar: compact presence indicators + caffeinate toggle All components use CSS custom properties (theme-compatible), prefers-reduced-motion handling, and proper TypeScript types. Dependencies: +qrcode.react
1 parent 1805873 commit d1b6c81

22 files changed

+2544
-1270
lines changed

app/page.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ import { PreviewProvider } from '@/context/preview-context'
2626
import { ViewRouter } from '@/components/view-router'
2727
import { StatusBar } from '@/components/status-bar'
2828
import { useKeyboardShortcuts } from '@/components/keyboard-handler'
29-
import { SidebarPluginSlot } from '@/components/sidebar-plugin-slot'
3029
import { emit, on } from '@/lib/events'
3130
import { KnotLogo } from '@/components/knot-logo'
3231
import type { AppMode } from '@/lib/mode-registry'
32+
import { openNewEditorInstance } from '@/lib/tauri'
3333

3434
const GitSidebarPanel = dynamic(
3535
() => import('@/components/git-sidebar-panel').then((m) => ({ default: m.GitSidebarPanel })),
@@ -261,6 +261,9 @@ export default function EditorLayout() {
261261
onQuickOpen: () => setQuickOpenVisible((v) => !v),
262262
onCommandPalette: () => setCommandPaletteVisible((v) => !v),
263263
onGlobalSearch: () => setGlobalSearchVisible((v) => !v),
264+
onNewWindow: () => {
265+
openNewEditorInstance().catch((err) => console.error('Failed to open new window:', err))
266+
},
264267
onFlashTab: (v) => {
265268
setFlashedTab(v)
266269
setTimeout(() => setFlashedTab(null), 400)
@@ -732,9 +735,6 @@ export default function EditorLayout() {
732735
{/* Git sidebar panel — Codex-style always-visible right panel */}
733736
{mode !== 'tui' && layout.isVisible('gitPanel') && <GitSidebarPanel />}
734737

735-
{/* Sidebar plugins (Spotify, etc.) */}
736-
<SidebarPluginSlot />
737-
738738
{/* Plugins */}
739739
<SpotifyPlugin />
740740
<YouTubePlugin />
@@ -829,6 +829,11 @@ export default function EditorLayout() {
829829
case 'open-onboarding':
830830
setOnboardingOpen(true)
831831
break
832+
case 'open-new-window':
833+
openNewEditorInstance().catch((err) =>
834+
console.error('Failed to open new window:', err),
835+
)
836+
break
832837
}
833838
}}
834839
/>

components/agent-approval.tsx

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
'use client'
2+
3+
import { useState, useEffect, useCallback } from 'react'
4+
import { Icon } from '@iconify/react'
5+
import { useGateway } from '@/context/gateway-context'
6+
7+
interface ApprovalRequest {
8+
id: string
9+
type: 'tool_call' | 'file_write' | 'command' | 'network' | 'generic'
10+
title: string
11+
description: string
12+
details?: string
13+
timestamp: number
14+
sessionKey: string
15+
}
16+
17+
/**
18+
* Agent Approval Panel — Shows pending approval requests from the agent.
19+
* Enables approve/deny from any device (desktop or mobile).
20+
* The gateway broadcasts approval events to all connected clients.
21+
*/
22+
export function AgentApproval() {
23+
const { onEvent, sendRequest, status } = useGateway()
24+
const [approvals, setApprovals] = useState<ApprovalRequest[]>([])
25+
const [expandedId, setExpandedId] = useState<string | null>(null)
26+
27+
useEffect(() => {
28+
if (status !== 'connected') return
29+
30+
const unsub = onEvent('agent.approval.request', (data: any) => {
31+
const request: ApprovalRequest = {
32+
id: data.id || crypto.randomUUID(),
33+
type: data.type || 'generic',
34+
title: data.title || 'Agent needs approval',
35+
description: data.description || data.message || '',
36+
details: data.details || data.code || data.command,
37+
timestamp: Date.now(),
38+
sessionKey: data.sessionKey || '',
39+
}
40+
setApprovals((prev) => [request, ...prev])
41+
42+
// Vibrate on mobile if supported
43+
if ('vibrate' in navigator) {
44+
navigator.vibrate([100, 50, 100])
45+
}
46+
})
47+
48+
return unsub
49+
}, [onEvent, status])
50+
51+
const respond = useCallback(
52+
async (id: string, approved: boolean) => {
53+
try {
54+
await sendRequest('agent.approval.respond', { id, approved })
55+
setApprovals((prev) => prev.filter((a) => a.id !== id))
56+
} catch (err) {
57+
console.error('Failed to respond to approval:', err)
58+
}
59+
},
60+
[sendRequest],
61+
)
62+
63+
const typeIcon = (type: ApprovalRequest['type']) => {
64+
switch (type) {
65+
case 'tool_call':
66+
return 'lucide:wrench'
67+
case 'file_write':
68+
return 'lucide:file-edit'
69+
case 'command':
70+
return 'lucide:terminal'
71+
case 'network':
72+
return 'lucide:globe'
73+
default:
74+
return 'lucide:shield-question'
75+
}
76+
}
77+
78+
const typeColor = (type: ApprovalRequest['type']) => {
79+
switch (type) {
80+
case 'command':
81+
return 'var(--brand)'
82+
case 'file_write':
83+
return '#f59e0b'
84+
case 'network':
85+
return '#3b82f6'
86+
default:
87+
return 'var(--text-secondary)'
88+
}
89+
}
90+
91+
if (approvals.length === 0) return null
92+
93+
return (
94+
<div className="agent-approval">
95+
<div className="agent-approval__badge">
96+
<Icon icon="lucide:shield-alert" width={14} />
97+
<span>{approvals.length} pending</span>
98+
</div>
99+
100+
<div className="agent-approval__list">
101+
{approvals.map((approval) => (
102+
<div key={approval.id} className="agent-approval__card">
103+
<div className="agent-approval__card-header">
104+
<Icon
105+
icon={typeIcon(approval.type)}
106+
width={16}
107+
style={{ color: typeColor(approval.type) }}
108+
/>
109+
<span className="agent-approval__card-title">{approval.title}</span>
110+
<span className="agent-approval__card-time">{formatTime(approval.timestamp)}</span>
111+
</div>
112+
113+
<p className="agent-approval__card-desc">{approval.description}</p>
114+
115+
{approval.details && (
116+
<button
117+
className="agent-approval__toggle"
118+
onClick={() => setExpandedId(expandedId === approval.id ? null : approval.id)}
119+
>
120+
<Icon
121+
icon={expandedId === approval.id ? 'lucide:chevron-up' : 'lucide:chevron-down'}
122+
width={12}
123+
/>
124+
{expandedId === approval.id ? 'Hide' : 'Show'} details
125+
</button>
126+
)}
127+
128+
{expandedId === approval.id && approval.details && (
129+
<pre className="agent-approval__details">{approval.details}</pre>
130+
)}
131+
132+
<div className="agent-approval__actions">
133+
<button
134+
className="agent-approval__btn agent-approval__btn--deny"
135+
onClick={() => respond(approval.id, false)}
136+
>
137+
<Icon icon="lucide:x" width={14} />
138+
Deny
139+
</button>
140+
<button
141+
className="agent-approval__btn agent-approval__btn--approve"
142+
onClick={() => respond(approval.id, true)}
143+
>
144+
<Icon icon="lucide:check" width={14} />
145+
Approve
146+
</button>
147+
</div>
148+
</div>
149+
))}
150+
</div>
151+
152+
<style jsx>{`
153+
.agent-approval {
154+
display: flex;
155+
flex-direction: column;
156+
gap: 8px;
157+
}
158+
.agent-approval__badge {
159+
display: flex;
160+
align-items: center;
161+
gap: 6px;
162+
font-size: 12px;
163+
font-weight: 600;
164+
color: #f59e0b;
165+
padding: 6px 10px;
166+
background: rgba(245, 158, 11, 0.1);
167+
border: 1px solid rgba(245, 158, 11, 0.2);
168+
border-radius: 8px;
169+
animation: approval-pulse 2s ease-in-out infinite;
170+
}
171+
@keyframes approval-pulse {
172+
0%,
173+
100% {
174+
border-color: rgba(245, 158, 11, 0.2);
175+
}
176+
50% {
177+
border-color: rgba(245, 158, 11, 0.5);
178+
}
179+
}
180+
.agent-approval__list {
181+
display: flex;
182+
flex-direction: column;
183+
gap: 8px;
184+
}
185+
.agent-approval__card {
186+
display: flex;
187+
flex-direction: column;
188+
gap: 8px;
189+
padding: 12px;
190+
background: var(--bg-elevated);
191+
border: 1px solid var(--border);
192+
border-radius: 10px;
193+
animation: slide-in 0.2s ease-out;
194+
}
195+
@keyframes slide-in {
196+
from {
197+
opacity: 0;
198+
transform: translateY(-8px);
199+
}
200+
to {
201+
opacity: 1;
202+
transform: translateY(0);
203+
}
204+
}
205+
.agent-approval__card-header {
206+
display: flex;
207+
align-items: center;
208+
gap: 8px;
209+
}
210+
.agent-approval__card-title {
211+
flex: 1;
212+
font-size: 13px;
213+
font-weight: 600;
214+
color: var(--text-primary);
215+
}
216+
.agent-approval__card-time {
217+
font-size: 11px;
218+
color: var(--text-muted);
219+
}
220+
.agent-approval__card-desc {
221+
font-size: 12px;
222+
color: var(--text-secondary);
223+
margin: 0;
224+
line-height: 1.5;
225+
}
226+
.agent-approval__toggle {
227+
display: flex;
228+
align-items: center;
229+
gap: 4px;
230+
font-size: 11px;
231+
color: var(--text-muted);
232+
background: none;
233+
border: none;
234+
cursor: pointer;
235+
padding: 2px 0;
236+
}
237+
.agent-approval__toggle:hover {
238+
color: var(--text-secondary);
239+
}
240+
.agent-approval__details {
241+
font-size: 11px;
242+
font-family: var(--font-mono, monospace);
243+
color: var(--text-secondary);
244+
background: var(--bg-primary);
245+
padding: 8px 10px;
246+
border-radius: 6px;
247+
overflow-x: auto;
248+
max-height: 120px;
249+
margin: 0;
250+
white-space: pre-wrap;
251+
word-break: break-all;
252+
}
253+
.agent-approval__actions {
254+
display: flex;
255+
gap: 8px;
256+
margin-top: 4px;
257+
}
258+
.agent-approval__btn {
259+
flex: 1;
260+
display: flex;
261+
align-items: center;
262+
justify-content: center;
263+
gap: 6px;
264+
padding: 8px 12px;
265+
border-radius: 8px;
266+
font-size: 13px;
267+
font-weight: 600;
268+
cursor: pointer;
269+
border: 1px solid var(--border);
270+
transition: all 0.15s;
271+
}
272+
.agent-approval__btn--deny {
273+
background: transparent;
274+
color: var(--text-secondary);
275+
}
276+
.agent-approval__btn--deny:hover {
277+
background: rgba(239, 68, 68, 0.1);
278+
border-color: rgba(239, 68, 68, 0.3);
279+
color: #ef4444;
280+
}
281+
.agent-approval__btn--approve {
282+
background: var(--brand);
283+
color: white;
284+
border-color: var(--brand);
285+
}
286+
.agent-approval__btn--approve:hover {
287+
filter: brightness(1.1);
288+
}
289+
@media (prefers-reduced-motion: reduce) {
290+
@keyframes slide-in {
291+
from,
292+
to {
293+
opacity: 1;
294+
transform: none;
295+
}
296+
}
297+
@keyframes approval-pulse {
298+
0%,
299+
100% {
300+
border-color: rgba(245, 158, 11, 0.2);
301+
}
302+
}
303+
}
304+
`}</style>
305+
</div>
306+
)
307+
}
308+
309+
function formatTime(ts: number): string {
310+
const diff = Date.now() - ts
311+
if (diff < 60000) return 'just now'
312+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
313+
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
314+
}

0 commit comments

Comments
 (0)