Skip to content
Open
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
3 changes: 1 addition & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"build": "bun build src/index.ts --outdir=dist --target=bun",
"typecheck": "tsc --noEmit",
"test": "pnpm run test:bun && pnpm run test:vitest",
"test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts test/routes/internal/repo-mirror.test.ts src/db/model-state.test.ts src/routes/providers.test.ts",
"test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts src/db/model-state.test.ts src/routes/providers.test.ts",
"test:vitest": "vitest run",
"test:ui": "vitest --ui",
"test:watch": "vitest --watch",
Expand All @@ -23,7 +23,6 @@
"archiver": "^7.0.1",
"better-auth": "^1.4.17",
"croner": "^10.0.1",
"dotenv": "^17.2.3",
"eventsource": "^4.1.0",
"hono": "^4.11.7",
"jsonc-parser": "^3.3.1",
Expand Down
27 changes: 16 additions & 11 deletions backend/src/routes/internal/repo-mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { spawn } from 'child_process'
import { pipeline } from 'stream/promises'
import { Readable } from 'stream'
import { mkdtempSync, mkdirSync, readdirSync, statSync, existsSync, writeFileSync } from 'fs'
import { join } from 'path'
import { dirname, join } from 'path'
import * as fsp from 'fs/promises'
import { getReposPath } from '@opencode-manager/shared/config/env'
import { getRepoById, updateLastPulled, updateRepoBranch, deleteRepo } from '../../db/queries'
Expand Down Expand Up @@ -60,7 +60,7 @@ export function createInternalRepoMirrorRoutes(db: Database) {
}

let staging: string | undefined
let oldDirMoved = false
let backupDir: string | undefined
try {
const stagingParent = join(getReposPath(), '.ocm-staging')
mkdirSync(stagingParent, { recursive: true })
Expand Down Expand Up @@ -100,17 +100,19 @@ export function createInternalRepoMirrorRoutes(db: Database) {
} catch { /* ignore stat errors */ }
}

await fsp.mkdir(dirname(fullPath), { recursive: true })

if (existsSync(fullPath)) {
try {
await fsp.rename(fullPath, fullPath + '.ocm-old')
oldDirMoved = true
} catch { /* ignore rename errors */ }
backupDir = `${fullPath}.ocm-old-${Date.now()}-${Math.random().toString(36).slice(2)}`
await fsp.rename(fullPath, backupDir)
}

await fsp.rename(extractedRoot, fullPath)

const oldDir = fullPath + '.ocm-old'
await fsp.rm(oldDir, { recursive: true, force: true }).catch(() => {})
if (backupDir) {
await fsp.rm(backupDir, { recursive: true, force: true }).catch(() => {})
backupDir = undefined
}

const branchName = await safeGitOut(fullPath, ['rev-parse', '--abbrev-ref', 'HEAD'])
const head = await safeGitOut(fullPath, ['rev-parse', 'HEAD'])
Expand All @@ -132,11 +134,14 @@ export function createInternalRepoMirrorRoutes(db: Database) {
} catch (error) {
logger.error('mirror POST failed:', error)

if (oldDirMoved) {
if (backupDir) {
try {
await fsp.rename(fullPath + '.ocm-old', fullPath)
if (existsSync(fullPath)) {
await fsp.rm(fullPath, { recursive: true, force: true })
}
await fsp.rename(backupDir, fullPath)
} catch {
logger.error('failed to restore old repo from .ocm-old')
logger.error('failed to restore old repo from backup')
}
}

Expand Down
24 changes: 24 additions & 0 deletions backend/test/routes/internal/repo-mirror.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,30 @@ describe('internal-repo-mirror routes', () => {
expect(mockCreateRepoRow).toHaveBeenCalled()
})

it('creates the mirror target parent before final rename', async () => {
const targetPath = join(getTmpRoot(), 'nested', 'test-repo')
mockEnsureMirrorTargetPath.mockReturnValue({ localPath: 'nested/test-repo', fullPath: targetPath })
mockCreateRepoRow.mockImplementation((_db: any, input: any) => ({ repo: { id: 1, fullPath: input.fullPath, localPath: input.localPath }, created: true }))

const sourceDir = join(getTmpRoot(), 'source-nested')
mkdirSync(sourceDir, { recursive: true })
writeFileSync(join(sourceDir, 'payload.txt'), 'payload data')

const result = spawnSync('tar', ['-c', '-C', sourceDir, '.'], { encoding: 'buffer' })
const tarball = result.stdout as Buffer

const res = await app.request('/api/internal/repos/0/mirror?create=1&name=test-repo', {
method: 'POST',
body: tarball,
headers: { 'content-type': 'application/x-tar' },
})

expect(res.status).toBe(200)
const json = (await res.json()) as { fullPath: string }
expect(json.fullPath).toBe(targetPath)
expect(existsSync(join(targetPath, 'payload.txt'))).toBe(true)
})

it('returns 409 when repo is in use and force not set', async () => {
const repoDir = join(getTmpRoot(), 'test-repo')
mkdirSync(repoDir, { recursive: true })
Expand Down
4 changes: 0 additions & 4 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.7.0",
"@opencode-manager/shared": "workspace:*",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
Expand All @@ -33,12 +32,10 @@
"better-auth": "^1.4.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cronstrue": "^3.13.0",
"date-fns": "^4.1.0",
"diff": "^8.0.2",
"highlight.js": "^11.11.1",
"jsonc-parser": "^3.3.1",
"lucide-react": "^0.546.0",
"mermaid": "^11.12.2",
"react": "^19.1.1",
Expand All @@ -63,7 +60,6 @@
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.4",
"autoprefixer": "^10.4.21",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
Expand Down
9 changes: 3 additions & 6 deletions frontend/src/components/message/PromptInput.stt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,6 @@ vi.mock('@/stores/sessionAgentStore', () => ({
useSessionAgentStore: mocks.useSessionAgentStore,
}))

vi.mock('@/hooks/useAgents', () => ({
useAgents: mocks.useAgents,
}))

vi.mock('@/contexts/EventContext', () => ({
usePermissions: () => ({
hasForSession: vi.fn().mockReturnValue(false),
Expand Down Expand Up @@ -399,7 +395,7 @@ describe('PromptInput STT Gesture Tests', () => {
expect(voiceButtons.length).toBeGreaterThan(0)
})

it('does NOT render mobile in-row ArrowDown button', async () => {
it('renders mobile in-row Latest button when showScrollButton is true', async () => {
mocks.useMobile.mockReturnValue(true)

render(
Expand All @@ -408,7 +404,8 @@ describe('PromptInput STT Gesture Tests', () => {
</QueryClientProvider>
)

expect(screen.queryByTitle('Scroll to bottom')).not.toBeInTheDocument()
expect(screen.getByTitle('Scroll to bottom')).toBeInTheDocument()
expect(screen.getByText('Latest')).toBeInTheDocument()
})
})
})
34 changes: 24 additions & 10 deletions frontend/src/components/message/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ interface PromptInputProps {
showScrollButton?: boolean
isSessionActive?: boolean
isStreamingResponse?: boolean
onScrollToBottom?: () => void
onScrollToBottom: () => void
onShowSessionsDialog?: () => void
onShowModelsDialog?: () => void
onShowHelpDialog?: () => void
Expand Down Expand Up @@ -1034,6 +1034,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
const { hasVariants, currentVariant, cycleVariant } = useVariants(opcodeUrl, directory)
const showStopButton = isSessionActive
const hideSecondaryButtons = isMobile && isSessionActive
const showMobileScrollButton = isMobile && showScrollButton
const voiceFeedbackState: VoiceStatusOverlayState | null = isTogglingRecording
? 'starting'
: isProcessing
Expand Down Expand Up @@ -1211,20 +1212,33 @@ return (

<div className="flex gap-1.5 md:gap-2 items-center justify-between">
<div className="flex gap-1.5 md:gap-2 items-center min-w-0">
<AgentQuickSelect
opcodeUrl={opcodeUrl}
directory={directory}
currentAgent={currentMode}
onAgentChange={handleAgentChange}
isBashMode={isBashMode}
disabled={disabled}
/>
{showMobileScrollButton ? (
<button
type="button"
onClick={onScrollToBottom}
className="flex items-center gap-1.5 px-3 min-h-[36px] rounded-lg text-xs font-medium border bg-zinc-950/80 hover:bg-zinc-900/90 text-blue-300 hover:text-blue-200 border-blue-400/20 shadow-md backdrop-blur-md transition-all duration-200 active:scale-95 ring-1 ring-blue-400/15"
title="Scroll to bottom"
aria-label="Scroll to bottom"
>
<ArrowDown className="w-4 h-4" />
<span>Latest</span>
</button>
) : (
<AgentQuickSelect
opcodeUrl={opcodeUrl}
directory={directory}
currentAgent={currentMode}
onAgentChange={handleAgentChange}
isBashMode={isBashMode}
disabled={disabled}
/>
)}
{isSessionActive ? (
<div className="px-2.5 py-1.5 md:px-3 md:py-2 rounded-lg text-xs md:text-sm font-medium text-muted-foreground max-w-[120px] md:max-w-[180px]">
<SessionStatusIndicator sessionID={sessionID} showLabel />
</div>
) : (
!hideSecondaryButtons && (
!hideSecondaryButtons && !showMobileScrollButton && (
<ModelQuickSelect
opcodeUrl={opcodeUrl}
directory={directory}
Expand Down
20 changes: 11 additions & 9 deletions frontend/src/components/repo/RepoSkillsDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ describe('RepoSkillsDialog', () => {
})

describe('load functionality', () => {
it('shows Load button when sessionId and opcodeUrl are provided', async () => {
it('makes skill cards clickable when sessionId and opcodeUrl are provided', async () => {
mocks.listManagedSkills.mockResolvedValue(mockSkills)
mocks.sendCommand.mockResolvedValue(undefined)

Expand All @@ -173,10 +173,11 @@ describe('RepoSkillsDialog', () => {
expect(screen.getByText('Test skill')).toBeInTheDocument()
})

expect(screen.getByText('Load')).toBeInTheDocument()
const card = screen.getByText('Test skill').closest('[class*="cursor-pointer"]')
expect(card).toBeTruthy()
})

it('does not show Load button when sessionId is not provided', async () => {
it('does not make skill cards clickable when sessionId is not provided', async () => {
mocks.listManagedSkills.mockResolvedValue(mockSkills)

render(
Expand All @@ -192,10 +193,11 @@ describe('RepoSkillsDialog', () => {
expect(screen.getByText('Test skill')).toBeInTheDocument()
})

expect(screen.queryByText('Load')).not.toBeInTheDocument()
const card = screen.getByText('Test skill').closest('[class*="cursor-pointer"]')
expect(card).toBeNull()
})

it('calls sendCommand and closes dialog on Load click', async () => {
it('calls sendCommand and closes dialog on card click', async () => {
mocks.listManagedSkills.mockResolvedValue(mockSkills)
mocks.sendCommand.mockReturnValue(new Promise(() => {}))

Expand All @@ -220,8 +222,8 @@ describe('RepoSkillsDialog', () => {
expect(screen.getByText('Test skill')).toBeInTheDocument()
})

const loadButton = screen.getByText('Load')
await user.click(loadButton)
const card = screen.getByText('Test skill').closest('[class*="cursor-pointer"]')!
await user.click(card)

expect(onOpenChange).toHaveBeenCalledWith(false)
expect(onSkillLoaded).toHaveBeenCalledWith(mockSkills[0])
Expand Down Expand Up @@ -255,8 +257,8 @@ describe('RepoSkillsDialog', () => {
expect(screen.getByText('Test skill')).toBeInTheDocument()
})

const loadButton = screen.getByText('Load')
await user.click(loadButton)
const card = screen.getByText('Test skill').closest('[class*="cursor-pointer"]')!
await user.click(card)

await waitFor(() => {
expect(showToast.error).toHaveBeenCalledWith('Failed to load')
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/repo/RepoSkillsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ export function RepoSkillsList({
{data.map((skill) => (
<div
key={skill.name}
onClick={() => onLoad?.(skill)}
className={`p-2 rounded-lg border border-border bg-card flex items-center justify-between gap-2 ${onLoad ? 'cursor-pointer hover:bg-accent transition-colors' : ''}`}
onClick={onLoad ? () => onLoad(skill) : undefined}
className={`p-2 rounded-lg border border-border bg-card flex items-center gap-2 ${onLoad ? 'cursor-pointer hover:bg-accent transition-colors' : ''}`}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate text-orange-600 dark:text-orange-400">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ui/dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ describe("DialogContent", () => {
const content = screen.getByTestId("dialog-content");
expect(content).toHaveClass("custom-class");
expect(content).toHaveClass("fixed");
expect(content).toHaveClass("z-50");
expect(content).toHaveClass("z-[70]");
});

it("renders children correctly", () => {
Expand Down
Loading