Merge branch 'main' into feat/39-structured-logging
This commit is contained in:
commit
a79a6c22dd
12
Dockerfile
12
Dockerfile
|
|
@ -3,19 +3,21 @@ RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
# Copy only dependency manifests first for better layer caching
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
COPY . .
|
COPY pnpm-lock.yaml* ./
|
||||||
# better-sqlite3 requires native compilation tools
|
# better-sqlite3 requires native compilation tools
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y python3 make g++ --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
||||||
RUN if [ -f pnpm-lock.yaml ]; then \
|
RUN if [ -f pnpm-lock.yaml ]; then \
|
||||||
pnpm install --frozen-lockfile; \
|
pnpm install --frozen-lockfile; \
|
||||||
else \
|
else \
|
||||||
echo "WARN: pnpm-lock.yaml not found in build context; running non-frozen install"; \
|
echo "WARN: pnpm-lock.yaml not found in build context; running non-frozen install" && \
|
||||||
pnpm install --no-frozen-lockfile; \
|
pnpm install --no-frozen-lockfile; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
COPY --from=deps /app ./
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
FROM node:20-slim AS runtime
|
FROM node:20-slim AS runtime
|
||||||
|
|
@ -24,8 +26,8 @@ ENV NODE_ENV=production
|
||||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||||
COPY --from=build /app/.next/standalone ./
|
COPY --from=build /app/.next/standalone ./
|
||||||
COPY --from=build /app/.next/static ./.next/static
|
COPY --from=build /app/.next/static ./.next/static
|
||||||
# Copy public directory if it exists (may not exist in all setups)
|
# Copy schema.sql needed by migration 001_init at runtime
|
||||||
COPY --from=build /app/public* ./public/
|
COPY --from=build /app/src/lib/schema.sql ./src/lib/schema.sql
|
||||||
# Create data directory with correct ownership for SQLite
|
# Create data directory with correct ownership for SQLite
|
||||||
RUN mkdir -p .data && chown nextjs:nodejs .data
|
RUN mkdir -p .data && chown nextjs:nodejs .data
|
||||||
RUN apt-get update && apt-get install -y curl --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y curl --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
services:
|
services:
|
||||||
mission-control:
|
mission-control:
|
||||||
build: .
|
build: .
|
||||||
|
container_name: mission-control
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "${MC_PORT:-3000}:3000"
|
||||||
env_file: .env
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
volumes:
|
volumes:
|
||||||
- mc-data:/app/.data
|
- mc-data:/app/.data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
turbopack: {},
|
turbopack: {},
|
||||||
|
// Transpile ESM-only packages so they resolve correctly in all environments
|
||||||
|
transpilePackages: ['react-markdown', 'remark-gfm'],
|
||||||
|
|
||||||
// Security headers
|
// Security headers
|
||||||
async headers() {
|
async headers() {
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,9 @@ export default function Home() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background overflow-hidden">
|
<div className="flex h-screen bg-background overflow-hidden">
|
||||||
|
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:top-2 focus:left-2 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md focus:text-sm focus:font-medium">
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
{/* Left: Icon rail navigation (hidden on mobile, shown as bottom bar instead) */}
|
{/* Left: Icon rail navigation (hidden on mobile, shown as bottom bar instead) */}
|
||||||
<NavRail />
|
<NavRail />
|
||||||
|
|
||||||
|
|
@ -149,7 +152,7 @@ export default function Home() {
|
||||||
<LocalModeBanner />
|
<LocalModeBanner />
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<PromoBanner />
|
<PromoBanner />
|
||||||
<main className="flex-1 overflow-auto pb-16 md:pb-0" role="main">
|
<main id="main-content" className="flex-1 overflow-auto pb-16 md:pb-0" role="main">
|
||||||
<div aria-live="polite">
|
<div aria-live="polite">
|
||||||
<ErrorBoundary key={activeTab}>
|
<ErrorBoundary key={activeTab}>
|
||||||
<ContentRouter tab={activeTab} />
|
<ContentRouter tab={activeTab} />
|
||||||
|
|
|
||||||
|
|
@ -1244,6 +1244,8 @@ export function ConfigTab({
|
||||||
const [jsonInput, setJsonInput] = useState('')
|
const [jsonInput, setJsonInput] = useState('')
|
||||||
const [availableModels, setAvailableModels] = useState<string[]>([])
|
const [availableModels, setAvailableModels] = useState<string[]>([])
|
||||||
const [newFallbackModel, setNewFallbackModel] = useState('')
|
const [newFallbackModel, setNewFallbackModel] = useState('')
|
||||||
|
const [newAllowTool, setNewAllowTool] = useState('')
|
||||||
|
const [newDenyTool, setNewDenyTool] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setConfig(agent.config || {})
|
setConfig(agent.config || {})
|
||||||
|
|
@ -1292,6 +1294,40 @@ export function ConfigTab({
|
||||||
setNewFallbackModel('')
|
setNewFallbackModel('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateIdentityField = (field: string, value: string) => {
|
||||||
|
setConfig((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
identity: { ...(prev.identity || {}), [field]: value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSandboxField = (field: string, value: string) => {
|
||||||
|
setConfig((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
sandbox: { ...(prev.sandbox || {}), [field]: value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTool = (list: 'allow' | 'deny', value: string) => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
setConfig((prev: any) => {
|
||||||
|
const tools = prev.tools || {}
|
||||||
|
const existing = Array.isArray(tools[list]) ? tools[list] : []
|
||||||
|
if (existing.includes(trimmed)) return prev
|
||||||
|
return { ...prev, tools: { ...tools, [list]: [...existing, trimmed] } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTool = (list: 'allow' | 'deny', index: number) => {
|
||||||
|
setConfig((prev: any) => {
|
||||||
|
const tools = prev.tools || {}
|
||||||
|
const existing = Array.isArray(tools[list]) ? [...tools[list]] : []
|
||||||
|
existing.splice(index, 1)
|
||||||
|
return { ...prev, tools: { ...tools, [list]: existing } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async (writeToGateway: boolean = false) => {
|
const handleSave = async (writeToGateway: boolean = false) => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
@ -1479,60 +1515,205 @@ export function ConfigTab({
|
||||||
{/* Identity */}
|
{/* Identity */}
|
||||||
<div className="bg-surface-1/50 rounded-lg p-4">
|
<div className="bg-surface-1/50 rounded-lg p-4">
|
||||||
<h5 className="text-sm font-medium text-foreground mb-2">Identity</h5>
|
<h5 className="text-sm font-medium text-foreground mb-2">Identity</h5>
|
||||||
<div className="flex items-center gap-3 text-sm">
|
{editing ? (
|
||||||
<span className="text-2xl">{identityEmoji}</span>
|
<div className="space-y-3">
|
||||||
<div>
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="text-foreground font-medium">{identityName}</div>
|
<div>
|
||||||
<div className="text-muted-foreground">{identityTheme}</div>
|
<label className="block text-xs text-muted-foreground mb-1">Emoji</label>
|
||||||
|
<input
|
||||||
|
value={identityEmoji}
|
||||||
|
onChange={(e) => updateIdentityField('emoji', e.target.value)}
|
||||||
|
className="w-full bg-surface-1 text-foreground rounded px-3 py-2 text-sm text-center focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
placeholder="🤖"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
value={identity.name || ''}
|
||||||
|
onChange={(e) => updateIdentityField('name', e.target.value)}
|
||||||
|
className="w-full bg-surface-1 text-foreground rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
placeholder="Agent name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Theme / Role</label>
|
||||||
|
<input
|
||||||
|
value={identity.theme || ''}
|
||||||
|
onChange={(e) => updateIdentityField('theme', e.target.value)}
|
||||||
|
className="w-full bg-surface-1 text-foreground rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
placeholder="e.g. backend engineer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Identity content</label>
|
||||||
|
<textarea
|
||||||
|
value={identity.content || ''}
|
||||||
|
onChange={(e) => updateIdentityField('content', e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
placeholder="Describe the agent's identity and personality..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
{identityPreview && (
|
<>
|
||||||
<pre className="mt-3 text-xs text-muted-foreground bg-surface-1 rounded p-2 overflow-auto whitespace-pre-wrap">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
{identityPreview}
|
<span className="text-2xl">{identityEmoji}</span>
|
||||||
</pre>
|
<div>
|
||||||
|
<div className="text-foreground font-medium">{identityName}</div>
|
||||||
|
<div className="text-muted-foreground">{identityTheme}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{identityPreview && (
|
||||||
|
<pre className="mt-3 text-xs text-muted-foreground bg-surface-1 rounded p-2 overflow-auto whitespace-pre-wrap">
|
||||||
|
{identityPreview}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sandbox */}
|
{/* Sandbox */}
|
||||||
<div className="bg-surface-1/50 rounded-lg p-4">
|
<div className="bg-surface-1/50 rounded-lg p-4">
|
||||||
<h5 className="text-sm font-medium text-foreground mb-2">Sandbox</h5>
|
<h5 className="text-sm font-medium text-foreground mb-2">Sandbox</h5>
|
||||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
{editing ? (
|
||||||
<div><span className="text-muted-foreground">Mode:</span> <span className="text-foreground">{sandboxMode}</span></div>
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div><span className="text-muted-foreground">Workspace:</span> <span className="text-foreground">{sandboxWorkspace}</span></div>
|
<div>
|
||||||
<div><span className="text-muted-foreground">Network:</span> <span className="text-foreground">{sandboxNetwork}</span></div>
|
<label className="block text-xs text-muted-foreground mb-1">Mode</label>
|
||||||
</div>
|
<select
|
||||||
|
value={sandbox.mode || ''}
|
||||||
|
onChange={(e) => updateSandboxField('mode', e.target.value)}
|
||||||
|
className="w-full bg-surface-1 text-foreground rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
>
|
||||||
|
<option value="">Not configured</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="non-main">Non-main</option>
|
||||||
|
<option value="none">None</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Workspace Access</label>
|
||||||
|
<select
|
||||||
|
value={sandbox.workspaceAccess || ''}
|
||||||
|
onChange={(e) => updateSandboxField('workspaceAccess', e.target.value)}
|
||||||
|
className="w-full bg-surface-1 text-foreground rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
>
|
||||||
|
<option value="">Not configured</option>
|
||||||
|
<option value="rw">Read-write</option>
|
||||||
|
<option value="ro">Read-only</option>
|
||||||
|
<option value="none">None</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Network</label>
|
||||||
|
<input
|
||||||
|
value={sandbox.network || ''}
|
||||||
|
onChange={(e) => updateSandboxField('network', e.target.value)}
|
||||||
|
className="w-full bg-surface-1 text-foreground rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
placeholder="none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<div><span className="text-muted-foreground">Mode:</span> <span className="text-foreground">{sandboxMode}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Workspace:</span> <span className="text-foreground">{sandboxWorkspace}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Network:</span> <span className="text-foreground">{sandboxNetwork}</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tools */}
|
{/* Tools */}
|
||||||
<div className="bg-surface-1/50 rounded-lg p-4">
|
<div className="bg-surface-1/50 rounded-lg p-4">
|
||||||
<h5 className="text-sm font-medium text-foreground mb-2">Tools</h5>
|
<h5 className="text-sm font-medium text-foreground mb-2">Tools</h5>
|
||||||
{toolAllow.length > 0 && (
|
{editing ? (
|
||||||
<div className="mb-2">
|
<div className="space-y-3">
|
||||||
<span className="text-xs text-green-400 font-medium">Allow ({toolAllow.length}):</span>
|
<div>
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<label className="block text-xs text-green-400 font-medium mb-1">Allow list</label>
|
||||||
{toolAllow.map((tool: string) => (
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
<span key={tool} className="px-2 py-0.5 text-xs bg-green-500/10 text-green-400 rounded border border-green-500/20">{tool}</span>
|
{toolAllow.map((tool: string, i: number) => (
|
||||||
))}
|
<span key={`${tool}-${i}`} className="px-2 py-0.5 text-xs bg-green-500/10 text-green-400 rounded border border-green-500/20 flex items-center gap-1">
|
||||||
|
{tool}
|
||||||
|
<button onClick={() => removeTool('allow', i)} className="text-green-400/60 hover:text-green-400 ml-1">×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={newAllowTool}
|
||||||
|
onChange={(e) => setNewAllowTool(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTool('allow', newAllowTool); setNewAllowTool('') } }}
|
||||||
|
placeholder="Add allowed tool name"
|
||||||
|
className="flex-1 bg-surface-1 text-foreground rounded px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => { addTool('allow', newAllowTool); setNewAllowTool('') }}
|
||||||
|
className="px-3 py-2 text-xs bg-green-500/20 text-green-400 border border-green-500/30 rounded hover:bg-green-500/30 transition-smooth"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-red-400 font-medium mb-1">Deny list</label>
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{toolDeny.map((tool: string, i: number) => (
|
||||||
|
<span key={`${tool}-${i}`} className="px-2 py-0.5 text-xs bg-red-500/10 text-red-400 rounded border border-red-500/20 flex items-center gap-1">
|
||||||
|
{tool}
|
||||||
|
<button onClick={() => removeTool('deny', i)} className="text-red-400/60 hover:text-red-400 ml-1">×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={newDenyTool}
|
||||||
|
onChange={(e) => setNewDenyTool(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTool('deny', newDenyTool); setNewDenyTool('') } }}
|
||||||
|
placeholder="Add denied tool name"
|
||||||
|
className="flex-1 bg-surface-1 text-foreground rounded px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => { addTool('deny', newDenyTool); setNewDenyTool('') }}
|
||||||
|
className="px-3 py-2 text-xs bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-smooth"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
{toolDeny.length > 0 && (
|
<>
|
||||||
<div>
|
{toolAllow.length > 0 && (
|
||||||
<span className="text-xs text-red-400 font-medium">Deny ({toolDeny.length}):</span>
|
<div className="mb-2">
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<span className="text-xs text-green-400 font-medium">Allow ({toolAllow.length}):</span>
|
||||||
{toolDeny.map((tool: string) => (
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
<span key={tool} className="px-2 py-0.5 text-xs bg-red-500/10 text-red-400 rounded border border-red-500/20">{tool}</span>
|
{toolAllow.map((tool: string) => (
|
||||||
))}
|
<span key={tool} className="px-2 py-0.5 text-xs bg-green-500/10 text-green-400 rounded border border-green-500/20">{tool}</span>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{toolAllow.length === 0 && toolDeny.length === 0 && !toolRawPreview && (
|
)}
|
||||||
<div className="text-xs text-muted-foreground">No tools configured</div>
|
{toolDeny.length > 0 && (
|
||||||
)}
|
<div>
|
||||||
{toolRawPreview && (
|
<span className="text-xs text-red-400 font-medium">Deny ({toolDeny.length}):</span>
|
||||||
<pre className="mt-3 text-xs text-muted-foreground bg-surface-1 rounded p-2 overflow-auto whitespace-pre-wrap">
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
{toolRawPreview}
|
{toolDeny.map((tool: string) => (
|
||||||
</pre>
|
<span key={tool} className="px-2 py-0.5 text-xs bg-red-500/10 text-red-400 rounded border border-red-500/20">{tool}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toolAllow.length === 0 && toolDeny.length === 0 && !toolRawPreview && (
|
||||||
|
<div className="text-xs text-muted-foreground">No tools configured</div>
|
||||||
|
)}
|
||||||
|
{toolRawPreview && (
|
||||||
|
<pre className="mt-3 text-xs text-muted-foreground bg-surface-1 rounded p-2 overflow-auto whitespace-pre-wrap">
|
||||||
|
{toolRawPreview}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useMissionControl } from '@/store'
|
import { useMissionControl } from '@/store'
|
||||||
import { useSmartPoll } from '@/lib/use-smart-poll'
|
import { useSmartPoll } from '@/lib/use-smart-poll'
|
||||||
|
|
||||||
import { createClientLogger } from '@/lib/client-logger'
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
|
import { useFocusTrap } from '@/lib/use-focus-trap'
|
||||||
|
|
||||||
import { AgentAvatar } from '@/components/ui/agent-avatar'
|
import { AgentAvatar } from '@/components/ui/agent-avatar'
|
||||||
import { MarkdownRenderer } from '@/components/markdown-renderer'
|
import { MarkdownRenderer } from '@/components/markdown-renderer'
|
||||||
|
|
||||||
|
|
@ -271,8 +275,8 @@ export function TaskBoardPanel() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64" role="status" aria-live="polite">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" aria-hidden="true"></div>
|
||||||
<span className="ml-2 text-muted-foreground">Loading tasks...</span>
|
<span className="ml-2 text-muted-foreground">Loading tasks...</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -301,11 +305,12 @@ export function TaskBoardPanel() {
|
||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-3 m-4 rounded-lg text-sm flex items-center justify-between">
|
<div role="alert" className="bg-red-500/10 border border-red-500/20 text-red-400 p-3 m-4 rounded-lg text-sm flex items-center justify-between">
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setError(null)}
|
onClick={() => setError(null)}
|
||||||
className="text-red-400/60 hover:text-red-400 ml-2"
|
className="text-red-400/60 hover:text-red-400 ml-2"
|
||||||
|
aria-label="Dismiss error"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -313,10 +318,12 @@ export function TaskBoardPanel() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Kanban Board */}
|
{/* Kanban Board */}
|
||||||
<div className="flex-1 flex gap-4 p-4 overflow-x-auto">
|
<div className="flex-1 flex gap-4 p-4 overflow-x-auto" role="region" aria-label="Task board">
|
||||||
{statusColumns.map(column => (
|
{statusColumns.map(column => (
|
||||||
<div
|
<div
|
||||||
key={column.key}
|
key={column.key}
|
||||||
|
role="region"
|
||||||
|
aria-label={`${column.title} column, ${tasksByStatus[column.key]?.length || 0} tasks`}
|
||||||
className="flex-1 min-w-80 bg-card border border-border rounded-lg flex flex-col"
|
className="flex-1 min-w-80 bg-card border border-border rounded-lg flex flex-col"
|
||||||
onDragEnter={(e) => handleDragEnter(e, column.key)}
|
onDragEnter={(e) => handleDragEnter(e, column.key)}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
|
|
@ -337,8 +344,12 @@ export function TaskBoardPanel() {
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
key={task.id}
|
||||||
draggable
|
draggable
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${task.title}, ${task.priority} priority, ${task.status}`}
|
||||||
onDragStart={(e) => handleDragStart(e, task)}
|
onDragStart={(e) => handleDragStart(e, task)}
|
||||||
onClick={() => setSelectedTask(task)}
|
onClick={() => setSelectedTask(task)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedTask(task) } }}
|
||||||
className={`bg-surface-1 rounded-lg p-3 cursor-pointer hover:bg-surface-2 transition-smooth border-l-4 ${priorityColors[task.priority]} ${
|
className={`bg-surface-1 rounded-lg p-3 cursor-pointer hover:bg-surface-2 transition-smooth border-l-4 ${priorityColors[task.priority]} ${
|
||||||
draggedTask?.id === task.id ? 'opacity-50' : ''
|
draggedTask?.id === task.id ? 'opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -613,12 +624,14 @@ function TaskDetailModal({
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const dialogRef = useFocusTrap(onClose)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
<div className="bg-card border border-border rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="task-detail-title" className="bg-card border border-border rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<h3 className="text-xl font-bold text-foreground">{task.title}</h3>
|
<h3 id="task-detail-title" className="text-xl font-bold text-foreground">{task.title}</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(task)}
|
onClick={() => onEdit(task)}
|
||||||
|
|
@ -628,6 +641,7 @@ function TaskDetailModal({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label="Close task details"
|
||||||
className="text-muted-foreground hover:text-foreground text-2xl transition-smooth"
|
className="text-muted-foreground hover:text-foreground text-2xl transition-smooth"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|
@ -641,10 +655,13 @@ function TaskDetailModal({
|
||||||
) : (
|
) : (
|
||||||
<p className="text-foreground/80 mb-4">No description</p>
|
<p className="text-foreground/80 mb-4">No description</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4" role="tablist" aria-label="Task detail tabs">
|
||||||
{(['details', 'comments', 'quality'] as const).map(tab => (
|
{(['details', 'comments', 'quality'] as const).map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab}
|
||||||
|
aria-controls={`tabpanel-${tab}`}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={`px-3 py-2 text-sm rounded-md transition-smooth ${
|
className={`px-3 py-2 text-sm rounded-md transition-smooth ${
|
||||||
activeTab === tab ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'
|
activeTab === tab ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'
|
||||||
|
|
@ -656,7 +673,7 @@ function TaskDetailModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'details' && (
|
{activeTab === 'details' && (
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
|
<div id="tabpanel-details" role="tabpanel" aria-label="Details" className="grid grid-cols-2 gap-4 text-sm mt-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Status:</span>
|
<span className="text-muted-foreground">Status:</span>
|
||||||
<span className="text-foreground ml-2">{task.status}</span>
|
<span className="text-foreground ml-2">{task.status}</span>
|
||||||
|
|
@ -686,7 +703,7 @@ function TaskDetailModal({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'comments' && (
|
{activeTab === 'comments' && (
|
||||||
<div className="mt-6">
|
<div id="tabpanel-comments" role="tabpanel" aria-label="Comments" className="mt-6">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h4 className="text-lg font-semibold text-foreground">Comments</h4>
|
<h4 className="text-lg font-semibold text-foreground">Comments</h4>
|
||||||
<button
|
<button
|
||||||
|
|
@ -769,7 +786,7 @@ function TaskDetailModal({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'quality' && (
|
{activeTab === 'quality' && (
|
||||||
<div className="mt-6">
|
<div id="tabpanel-quality" role="tabpanel" aria-label="Quality Review" className="mt-6">
|
||||||
<h5 className="text-sm font-medium text-foreground mb-2">Aegis Quality Review</h5>
|
<h5 className="text-sm font-medium text-foreground mb-2">Aegis Quality Review</h5>
|
||||||
{reviewError && (
|
{reviewError && (
|
||||||
<div className="text-xs text-red-400 mb-2">{reviewError}</div>
|
<div className="text-xs text-red-400 mb-2">{reviewError}</div>
|
||||||
|
|
@ -876,16 +893,19 @@ function CreateTaskModal({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dialogRef = useFocusTrap(onClose)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
<div className="bg-card border border-border rounded-lg max-w-md w-full">
|
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="create-task-title" className="bg-card border border-border rounded-lg max-w-md w-full">
|
||||||
<form onSubmit={handleSubmit} className="p-6">
|
<form onSubmit={handleSubmit} className="p-6">
|
||||||
<h3 className="text-xl font-bold text-foreground mb-4">Create New Task</h3>
|
<h3 id="create-task-title" className="text-xl font-bold text-foreground mb-4">Create New Task</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-muted-foreground mb-1">Title</label>
|
<label htmlFor="create-title" className="block text-sm text-muted-foreground mb-1">Title</label>
|
||||||
<input
|
<input
|
||||||
|
id="create-title"
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||||
|
|
@ -895,8 +915,9 @@ function CreateTaskModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-muted-foreground mb-1">Description</label>
|
<label htmlFor="create-description" className="block text-sm text-muted-foreground mb-1">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="create-description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
|
@ -906,8 +927,9 @@ function CreateTaskModal({
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-muted-foreground mb-1">Priority</label>
|
<label htmlFor="create-priority" className="block text-sm text-muted-foreground mb-1">Priority</label>
|
||||||
<select
|
<select
|
||||||
|
id="create-priority"
|
||||||
value={formData.priority}
|
value={formData.priority}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))}
|
||||||
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
|
@ -920,8 +942,9 @@ function CreateTaskModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-muted-foreground mb-1">Assign to</label>
|
<label htmlFor="create-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
|
||||||
<select
|
<select
|
||||||
|
id="create-assignee"
|
||||||
value={formData.assigned_to}
|
value={formData.assigned_to}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
|
||||||
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
|
@ -937,8 +960,9 @@ function CreateTaskModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
|
<label htmlFor="create-tags" className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
|
||||||
<input
|
<input
|
||||||
|
id="create-tags"
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.tags}
|
value={formData.tags}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))}
|
||||||
|
|
@ -1018,16 +1042,19 @@ function EditTaskModal({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dialogRef = useFocusTrap(onClose)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
<div className="bg-card border border-border rounded-lg max-w-md w-full">
|
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="edit-task-title" className="bg-card border border-border rounded-lg max-w-md w-full">
|
||||||
<form onSubmit={handleSubmit} className="p-6">
|
<form onSubmit={handleSubmit} className="p-6">
|
||||||
<h3 className="text-xl font-bold text-foreground mb-4">Edit Task</h3>
|
<h3 id="edit-task-title" className="text-xl font-bold text-foreground mb-4">Edit Task</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-muted-foreground mb-1">Title</label>
|
<label htmlFor="edit-title" className="block text-sm text-muted-foreground mb-1">Title</label>
|
||||||
<input
|
<input
|
||||||
|
id="edit-title"
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||||
|
|
@ -1037,8 +1064,9 @@ function EditTaskModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-muted-foreground mb-1">Description</label>
|
<label htmlFor="edit-description" className="block text-sm text-muted-foreground mb-1">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="edit-description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
|
@ -1048,8 +1076,9 @@ function EditTaskModal({
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-muted-foreground mb-1">Status</label>
|
<label htmlFor="edit-status" className="block text-sm text-muted-foreground mb-1">Status</label>
|
||||||
<select
|
<select
|
||||||
|
id="edit-status"
|
||||||
value={formData.status}
|
value={formData.status}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as Task['status'] }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as Task['status'] }))}
|
||||||
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
|
@ -1064,8 +1093,9 @@ function EditTaskModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-muted-foreground mb-1">Priority</label>
|
<label htmlFor="edit-priority" className="block text-sm text-muted-foreground mb-1">Priority</label>
|
||||||
<select
|
<select
|
||||||
|
id="edit-priority"
|
||||||
value={formData.priority}
|
value={formData.priority}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))}
|
||||||
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
|
@ -1079,8 +1109,9 @@ function EditTaskModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-muted-foreground mb-1">Assign to</label>
|
<label htmlFor="edit-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
|
||||||
<select
|
<select
|
||||||
|
id="edit-assignee"
|
||||||
value={formData.assigned_to}
|
value={formData.assigned_to}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
|
||||||
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
|
@ -1095,8 +1126,9 @@ function EditTaskModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
|
<label htmlFor="edit-tags" className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
|
||||||
<input
|
<input
|
||||||
|
id="edit-tags"
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.tags}
|
value={formData.tags}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR = [
|
||||||
|
'a[href]',
|
||||||
|
'button:not([disabled])',
|
||||||
|
'input:not([disabled])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(', ')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traps keyboard focus within a container element.
|
||||||
|
* Handles Tab/Shift+Tab cycling and Escape to close.
|
||||||
|
*/
|
||||||
|
export function useFocusTrap(onClose?: () => void) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const previousFocusRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && onClose) {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const focusable = Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (focusable.length === 0) return
|
||||||
|
|
||||||
|
const first = focusable[0]
|
||||||
|
const last = focusable[focusable.length - 1]
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
previousFocusRef.current = document.activeElement as HTMLElement
|
||||||
|
|
||||||
|
const container = containerRef.current
|
||||||
|
if (container) {
|
||||||
|
const focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||||
|
if (focusable.length > 0) {
|
||||||
|
focusable[0].focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
previousFocusRef.current?.focus()
|
||||||
|
}
|
||||||
|
}, [handleKeyDown])
|
||||||
|
|
||||||
|
return containerRef
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue