Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0a0a0a" />
<meta name="description" content="AI-powered coding assistant interface" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="OpenCode" />
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,9 @@ function AppShell() {
<EventProvider>
<div ref={rootRef} className="flex h-dvh w-full min-w-0">
<DesktopSidebar />
<div className="flex-1 min-w-0 min-h-0 flex flex-col">
<main className="flex-1 min-w-0 min-h-0 flex flex-col">
<Outlet />
</div>
</main>
</div>
<MobileTabBar />
<MobileSheetHost />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ScheduleJobDialog } from './ScheduleJobDialog'

// jsdom does not implement scrollIntoView
Element.prototype.scrollIntoView = vi.fn()

// Reproduces the "Maximum update depth exceeded" crash: while the prompt
// templates query is loading, the hook returns `data: undefined`. The
// component's `= EMPTY_TEMPLATES` fallback must stay referentially stable so
// the init effect does not re-fire every render.
vi.mock('@/hooks/usePromptTemplates', () => ({
usePromptTemplates: () => ({ data: undefined, isLoading: true }),
useCreatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }),
useUpdatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }),
useDeletePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }),
}))

vi.mock('@/api/providers', () => ({
getProvidersWithModels: () => Promise.resolve([]),
}))

vi.mock('@/api/opencode', () => ({
createOpenCodeClient: () => ({
listAgents: () => Promise.resolve([]),
getConfig: () => Promise.resolve(null),
}),
}))

vi.mock('@/api/settings', () => ({
settingsApi: {
listManagedSkills: () => Promise.resolve([]),
},
}))

vi.mock('@/api/repos', () => ({
listRepos: () => Promise.resolve([]),
listBranches: () => Promise.resolve({ branches: [], status: { ahead: 0, behind: 0 } }),
}))

function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}

describe('ScheduleJobDialog — templates loading', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('renders without an infinite render loop while templates are loading', async () => {
render(
<ScheduleJobDialog
open
onOpenChange={vi.fn()}
showRepoSelector
repoId={undefined}
onRepoChange={vi.fn()}
onSubmit={vi.fn()}
isSaving={false}
/>,
{ wrapper: createWrapper() },
)

await waitFor(() => {
expect(screen.getByText('New schedule')).toBeInTheDocument()
})
})
})
16 changes: 12 additions & 4 deletions frontend/src/components/schedules/ScheduleJobDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { TimingTab } from './TimingTab'
import { PromptTab } from './PromptTab'
import { SkillsTab } from './SkillsTab'

const EMPTY_TEMPLATES: PromptTemplate[] = []

type ScheduleJobDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
Expand Down Expand Up @@ -68,7 +70,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
const [editingTemplate, setEditingTemplate] = useState<PromptTemplate | undefined>(undefined)
const [deletingTemplateId, setDeletingTemplateId] = useState<number | null>(null)

const { data: templates = [] } = usePromptTemplates()
const { data: templates = EMPTY_TEMPLATES } = usePromptTemplates()
const deleteTemplateMutation = useDeletePromptTemplate()

const { data: providerModels = [] } = useQuery({
Expand Down Expand Up @@ -212,8 +214,6 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
setAgentSlug(job?.agentSlug ?? '')
setModel(job?.model ?? '')
setPrompt(job?.prompt ?? '')
const matchingTemplate = templates.find((template) => template.prompt === (job?.prompt ?? ''))
setSelectedPromptTemplateId(matchingTemplate ? matchingTemplate.id : null)
const initialSkillSlugs = job?.skillMetadata?.skillSlugs ?? []
const initialSkillNotes = job?.skillMetadata?.notes ?? ''
setSkillSlugs(initialSkillSlugs)
Expand All @@ -223,7 +223,15 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit,
setBranch(job?.branch ?? '')
setAllowExternalDirectory(job?.permissionConfig?.allowExternalDirectory ?? false)
setBashDenyPatterns(job?.permissionConfig?.bashDenyPatterns ?? [...DEFAULT_DESTRUCTIVE_BASH_PATTERNS])
}, [job, open, templates])
}, [job, open])

useEffect(() => {
if (!open) {
return
}
const matchingTemplate = templates.find((template) => template.prompt === (job?.prompt ?? ''))
setSelectedPromptTemplateId(matchingTemplate ? matchingTemplate.id : null)
}, [templates, job, open])

const applyPromptTemplate = (template: PromptTemplate) => {
setSelectedPromptTemplateId(template.id)
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/settings/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { AccountSettings } from '@/components/settings/AccountSettings'
import { VoiceSettings } from '@/components/settings/VoiceSettings'
import { NotificationSettings } from '@/components/settings/NotificationSettings'
import { VersionSelectDialog } from '@/components/settings/VersionSelectDialog'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Settings2, Keyboard, Code, ChevronLeft, Key, GitBranch, User, Volume2, Bell, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
Expand Down Expand Up @@ -109,6 +109,7 @@ export function SettingsDialog() {
onFocusOutside={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">Settings</DialogTitle>
<div className="hidden sm:flex sm:flex-col sm:h-full sm:min-h-0">
<div className="sticky top-0 z-10 bg-gradient-to-b from-background via-background to-transparent border-b border-border backdrop-blur-sm px-6 py-4 flex-shrink-0 flex items-center justify-between">
<h2 className="text-2xl font-semibold bg-gradient-to-r from-foreground to-muted-foreground bg-clip-text text-transparent">
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/ui/back-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function BackButton({ to = "/", className = "" }: BackButtonProps) {
return (
<button
onClick={handleBack}
aria-label="Go back"
className={`text-zinc-400 hover:text-zinc-100 transition-all duration-200 hover:scale-105 text-sm md:text-md border border-zinc-700 rounded-md px-3 py-1.5 hover ${className}`}
>
<ArrowLeft className="w-4 h-4" />
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/ui/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export function Combobox({
{showClear && value && (
<button
type="button"
aria-label="Clear"
onClick={() => {
if (!disabled) {
onChange('')
Expand All @@ -201,6 +202,7 @@ export function Combobox({
)}
<button
type="button"
aria-label="Toggle options"
onClick={() => {
if (!disabled) {
setIsOpen(!isOpen)
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/ui/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ function HeaderSettingsButton() {
variant="ghost"
size="icon"
onClick={open}
aria-label="Settings"
className="text-muted-foreground hover:text-foreground hover:bg-accent transition-all duration-200 h-8 w-8"
>
<Settings className="w-4 h-4" />
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/lib/serviceWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,27 @@ export function offServiceWorkerUpdate(): void {

const UPDATE_CHECK_INTERVAL_MS = 60 * 1000;

async function unregisterServiceWorkerAndClearCaches(): Promise<void> {
try {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.unregister()));
if ("caches" in window) {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
}
} catch {
return;
}
}

export function registerServiceWorker(): void {
if (!("serviceWorker" in navigator)) return;

if (import.meta.env.DEV) {
void unregisterServiceWorkerAndClearCaches();
return;
}

const hadController = !!navigator.serviceWorker.controller;

navigator.serviceWorker.addEventListener("message", (event) => {
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/pages/GlobalSchedules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ export function GlobalSchedules() {
<Button
onClick={() => { openNewJob(); setSelectedRepoId(undefined) }}
size="sm"
aria-label="New Schedule"
className="sm:hidden h-10 w-10 p-0"
>
<Plus className="w-5 h-5" />
Expand Down Expand Up @@ -361,7 +362,7 @@ export function GlobalSchedules() {
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="sm:hidden h-8 w-8 shrink-0 relative">
<Button variant="outline" size="icon" aria-label="Filters" className="sm:hidden h-8 w-8 shrink-0 relative">
<SlidersHorizontal className="h-3.5 w-3.5" />
{(statusFilter !== 'all' || scheduleModeFilter !== 'all') && (
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary" />
Expand Down Expand Up @@ -615,6 +616,7 @@ export function GlobalSchedules() {
variant="outline"
size="sm"
className="h-8 w-8 p-0"
aria-label={job.enabled ? 'Pause schedule' : 'Enable schedule'}
onClick={(e) => {
e.stopPropagation()
handleToggleEnabled(job)
Expand All @@ -630,6 +632,7 @@ export function GlobalSchedules() {
variant="outline"
size="sm"
className="h-8 w-8 p-0"
aria-label="Edit schedule"
onClick={(e) => {
e.stopPropagation()
handleEdit(job)
Expand All @@ -641,6 +644,7 @@ export function GlobalSchedules() {
variant="outline"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
aria-label="Delete schedule"
onClick={(e) => {
e.stopPropagation()
openDeleteJob(job.id)
Expand Down Expand Up @@ -670,7 +674,7 @@ export function GlobalSchedules() {
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="sm:hidden h-8 w-8 shrink-0 relative">
<Button variant="outline" size="icon" aria-label="Filters" className="sm:hidden h-8 w-8 shrink-0 relative">
<SlidersHorizontal className="h-3.5 w-3.5" />
{(runStatusFilter !== 'all' || runTriggerFilter !== 'all' || runSortOption !== 'startedAt') && (
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary" />
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/Repos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function Repos() {
variant="ghost"
size="icon"
onClick={() => setFileBrowserOpen(true)}
aria-label="Open files"
className="hidden sm:flex text-muted-foreground hover:text-foreground hover:bg-accent transition-all duration-200 h-8 w-8"
>
<FolderOpen className="w-4 h-4" />
Expand Down