clear the remaining type errors
- DiscordAccount seeded DiscordStatus without its two nullable fields. - bug-report typed reporter.name as string, but users.name is nullable; and the Discord upload wrapped a Buffer directly in a Blob. - Lucide icons take no `title` prop, so the sync spinner's tooltip moved to a wrapping span. - DesktopView cast its dynamic import to a type that included `| null`. - dock PUT cast the request body straight to string[]; it now rejects anything that is not an array of strings instead of writing it to the database. - buildZodSchema assembles a mutable record, since z.ZodRawShape is readonly in zod v4. - The dev-server proxy forwards Bun's `string | Buffer` frames through a helper that satisfies WebSocket.send without copying. bunx tsgo is now clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0041fcbd47
commit
0d67e2af26
@@ -1,7 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { ChevronLeft, ChevronRight, Inbox, Loader2, Mail, Paperclip, RefreshCw, Search, Send, ShieldAlert, SquarePen, Trash2, X } from 'lucide-react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Inbox,
|
||||
Loader2,
|
||||
Mail,
|
||||
Paperclip,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Send,
|
||||
ShieldAlert,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
@@ -66,8 +80,12 @@ export const EmailList = () => {
|
||||
queryKey: isSearching ? ['email-search', debouncedSearch, page] : ['email-messages', page, folder],
|
||||
queryFn: () =>
|
||||
isSearching
|
||||
? client.get<{ messages: EmailSummary[]; total: number }>(`/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}`)
|
||||
: client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`),
|
||||
? client.get<{ messages: EmailSummary[]; total: number }>(
|
||||
`/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}`,
|
||||
)
|
||||
: client.get<{ messages: EmailSummary[]; total: number }>(
|
||||
`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`,
|
||||
),
|
||||
// Keep the previous results visible while the next query loads, so switching search terms/pages
|
||||
// never bails to the full-panel loading view (which would unmount the search input and drop focus).
|
||||
placeholderData: keepPreviousData,
|
||||
@@ -117,7 +135,10 @@ export const EmailList = () => {
|
||||
if (!syncableAccount) return;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const result = await client.post<{ ok: boolean; saved?: number }>(`/email/accounts/${syncableAccount.id}/sync`, {});
|
||||
const result = await client.post<{ ok: boolean; saved?: number }>(
|
||||
`/email/accounts/${syncableAccount.id}/sync`,
|
||||
{},
|
||||
);
|
||||
if (result.saved !== undefined) {
|
||||
toast.success(result.saved > 0 ? `${result.saved} new emails` : 'No new emails');
|
||||
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
|
||||
@@ -174,11 +195,7 @@ export const EmailList = () => {
|
||||
}, [messages, selectedId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm opacity-50">
|
||||
Loading emails...
|
||||
</div>
|
||||
);
|
||||
return <div className="flex h-full items-center justify-center text-sm opacity-50">Loading emails...</div>;
|
||||
}
|
||||
|
||||
// Show onboarding empty state only when no emails exist at all (never while searching)
|
||||
@@ -198,7 +215,11 @@ export const EmailList = () => {
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span>No emails synced yet</span>
|
||||
<Button variant="outline" size="sm" onClick={handleSync} disabled={syncing || isSyncing}>
|
||||
{syncing || isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-2 h-3.5 w-3.5" />}
|
||||
{syncing || isSyncing ? (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-3.5 w-3.5" />
|
||||
)}
|
||||
Sync Now
|
||||
</Button>
|
||||
</div>
|
||||
@@ -238,7 +259,9 @@ export const EmailList = () => {
|
||||
</div>
|
||||
<span className="text-xs opacity-50">{total}</span>
|
||||
{syncing || isSyncing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0 opacity-50" title="Syncing..." />
|
||||
<span title="Syncing..." className="flex shrink-0">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin opacity-50" />
|
||||
</span>
|
||||
) : syncableAccount ? (
|
||||
<button
|
||||
onClick={handleSync}
|
||||
@@ -286,9 +309,17 @@ export const EmailList = () => {
|
||||
placeholder="Search — try from:, subject:, has:attachment…"
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40"
|
||||
/>
|
||||
{isSearching && <span className="text-xs opacity-50 shrink-0">{total} result{total === 1 ? '' : 's'}</span>}
|
||||
{isSearching && (
|
||||
<span className="text-xs opacity-50 shrink-0">
|
||||
{total} result{total === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="shrink-0 opacity-40 hover:opacity-100 cursor-pointer" title="Clear search">
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="shrink-0 opacity-40 hover:opacity-100 cursor-pointer"
|
||||
title="Clear search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
@@ -298,43 +329,48 @@ export const EmailList = () => {
|
||||
{isSearching ? `No results for “${debouncedSearch}”` : 'No emails in this folder'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
|
||||
{messages.map((msg: EmailSummary) => {
|
||||
const unread = msg.threadUnread !== undefined ? msg.threadUnread > 0 : !msg.read;
|
||||
return (
|
||||
<button
|
||||
key={msg.id}
|
||||
data-email-id={msg.id}
|
||||
onClick={() => setSelectedId(msg.id)}
|
||||
className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer shrink-0 ${
|
||||
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={`truncate text-sm ${unread ? 'font-semibold' : 'font-medium opacity-70'}`}>{msg.from}</span>
|
||||
{!!msg.threadCount && (
|
||||
<span className="shrink-0 rounded-full bg-muted px-1.5 text-xs tabular-nums opacity-60" title={`${msg.threadCount} messages`}>
|
||||
{msg.threadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span>
|
||||
</div>
|
||||
<span className={`truncate text-sm ${unread ? 'font-medium' : 'opacity-70'}`}>{msg.subject}</span>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{!!msg.attachmentCount && (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-muted-foreground">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{msg.attachmentCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate opacity-50">{msg.snippet}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
|
||||
{messages.map((msg: EmailSummary) => {
|
||||
const unread = msg.threadUnread !== undefined ? msg.threadUnread > 0 : !msg.read;
|
||||
return (
|
||||
<button
|
||||
key={msg.id}
|
||||
data-email-id={msg.id}
|
||||
onClick={() => setSelectedId(msg.id)}
|
||||
className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer shrink-0 ${
|
||||
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={`truncate text-sm ${unread ? 'font-semibold' : 'font-medium opacity-70'}`}>
|
||||
{msg.from}
|
||||
</span>
|
||||
{!!msg.threadCount && (
|
||||
<span
|
||||
className="shrink-0 rounded-full bg-muted px-1.5 text-xs tabular-nums opacity-60"
|
||||
title={`${msg.threadCount} messages`}
|
||||
>
|
||||
{msg.threadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span>
|
||||
</div>
|
||||
<span className={`truncate text-sm ${unread ? 'font-medium' : 'opacity-70'}`}>{msg.subject}</span>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{!!msg.attachmentCount && (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-muted-foreground">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{msg.attachmentCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate opacity-50">{msg.snippet}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+14
-8
@@ -20,7 +20,12 @@ export const DiscordAccount = () => {
|
||||
const client = useClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [connection, setConnection] = useState<DiscordConnection>({ linked: false });
|
||||
const [botStatus, setBotStatus] = useState<DiscordStatus>({ configured: false, running: false });
|
||||
const [botStatus, setBotStatus] = useState<DiscordStatus>({
|
||||
configured: false,
|
||||
running: false,
|
||||
serverInvite: null,
|
||||
botHandle: null,
|
||||
});
|
||||
const [pairingCode, setPairingCode] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
@@ -116,12 +121,7 @@ export const DiscordAccount = () => {
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
You can send direct messages to the bot on Discord and they will be handled by your PI agent.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
className="w-full h-11 cursor-pointer"
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
|
||||
Unlink Discord
|
||||
</Button>
|
||||
</div>
|
||||
@@ -142,7 +142,13 @@ export const DiscordAccount = () => {
|
||||
<code className="text-2xl font-mono font-bold tracking-[0.3em] text-duck-dark dark:text-foreground">
|
||||
{pairingCode}
|
||||
</code>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={handleCopyCode} className="cursor-pointer shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopyCode}
|
||||
className="cursor-pointer shrink-0"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user