diff --git a/backend/package.json b/backend/package.json index 17d0f0f9..637e07c5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", @@ -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", diff --git a/backend/src/routes/internal/repo-mirror.ts b/backend/src/routes/internal/repo-mirror.ts index 68c0a2bc..63d1f591 100644 --- a/backend/src/routes/internal/repo-mirror.ts +++ b/backend/src/routes/internal/repo-mirror.ts @@ -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' @@ -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 }) @@ -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']) @@ -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') } } diff --git a/backend/test/routes/internal/repo-mirror.test.ts b/backend/test/routes/internal/repo-mirror.test.ts index 402b9fd4..a438a664 100644 --- a/backend/test/routes/internal/repo-mirror.test.ts +++ b/backend/test/routes/internal/repo-mirror.test.ts @@ -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 }) diff --git a/frontend/package.json b/frontend/package.json index 0e6ef3a8..cd5c45be 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", @@ -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", @@ -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", diff --git a/frontend/src/components/message/PromptInput.stt.test.tsx b/frontend/src/components/message/PromptInput.stt.test.tsx index a37079ef..76d2a999 100644 --- a/frontend/src/components/message/PromptInput.stt.test.tsx +++ b/frontend/src/components/message/PromptInput.stt.test.tsx @@ -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), @@ -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( @@ -408,7 +404,8 @@ describe('PromptInput STT Gesture Tests', () => { ) - expect(screen.queryByTitle('Scroll to bottom')).not.toBeInTheDocument() + expect(screen.getByTitle('Scroll to bottom')).toBeInTheDocument() + expect(screen.getByText('Latest')).toBeInTheDocument() }) }) }) diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 58fb6f16..b6b907e9 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -67,7 +67,7 @@ interface PromptInputProps { showScrollButton?: boolean isSessionActive?: boolean isStreamingResponse?: boolean - onScrollToBottom?: () => void + onScrollToBottom: () => void onShowSessionsDialog?: () => void onShowModelsDialog?: () => void onShowHelpDialog?: () => void @@ -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 @@ -1211,20 +1212,33 @@ return (
diff --git a/frontend/src/components/ui/dialog.test.tsx b/frontend/src/components/ui/dialog.test.tsx
index 473fdd63..7e1ec386 100644
--- a/frontend/src/components/ui/dialog.test.tsx
+++ b/frontend/src/components/ui/dialog.test.tsx
@@ -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", () => {
diff --git a/frontend/src/hooks/__tests__/useHeaderScrollVisibility.test.tsx b/frontend/src/hooks/__tests__/useHeaderScrollVisibility.test.tsx
new file mode 100644
index 00000000..dbc5eb7d
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useHeaderScrollVisibility.test.tsx
@@ -0,0 +1,129 @@
+import { act, renderHook } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { useHeaderScrollVisibility } from '../useHeaderScrollVisibility'
+
+interface ScrollContainerOptions {
+ scrollTop?: number
+ scrollHeight?: number
+ clientHeight?: number
+}
+
+function createContainerRef({
+ scrollTop = 0,
+ scrollHeight = 1000,
+ clientHeight = 500,
+}: ScrollContainerOptions = {}) {
+ const el = document.createElement('div')
+ Object.defineProperty(el, 'scrollTop', { value: scrollTop, writable: true, configurable: true })
+ Object.defineProperty(el, 'scrollHeight', { value: scrollHeight, writable: true, configurable: true })
+ Object.defineProperty(el, 'clientHeight', { value: clientHeight, writable: true, configurable: true })
+ return { current: el }
+}
+
+function scroll(container: HTMLElement, scrollTop: number, scrollHeight?: number) {
+ act(() => {
+ container.scrollTop = scrollTop
+ if (scrollHeight !== undefined) {
+ Object.defineProperty(container, 'scrollHeight', { value: scrollHeight, writable: true, configurable: true })
+ }
+ container.dispatchEvent(new Event('scroll'))
+ })
+}
+
+describe('useHeaderScrollVisibility', () => {
+ it('returns true initially', () => {
+ const ref = createContainerRef()
+
+ const { result } = renderHook(() => useHeaderScrollVisibility({ containerRef: ref, enabled: true }))
+
+ expect(result.current.isHeaderVisible).toBe(true)
+ })
+
+ it('does not hide the header when disabled', () => {
+ const ref = createContainerRef({ scrollTop: 100 })
+ const addEventListenerSpy = vi.spyOn(ref.current, 'addEventListener')
+
+ const { result } = renderHook(() => useHeaderScrollVisibility({ containerRef: ref, enabled: false }))
+ scroll(ref.current, 200)
+
+ expect(result.current.isHeaderVisible).toBe(true)
+ expect(addEventListenerSpy).not.toHaveBeenCalledWith('scroll', expect.any(Function), expect.anything())
+ })
+
+ it('hides the header when scrolling down past the threshold', () => {
+ const ref = createContainerRef({ scrollTop: 100 })
+
+ const { result } = renderHook(() => useHeaderScrollVisibility({ containerRef: ref, enabled: true }))
+ scroll(ref.current, 200)
+
+ expect(result.current.isHeaderVisible).toBe(false)
+ })
+
+ it('shows the header after being hidden when scrolling up past the threshold', () => {
+ const ref = createContainerRef({ scrollTop: 100 })
+
+ const { result } = renderHook(() => useHeaderScrollVisibility({ containerRef: ref, enabled: true }))
+ scroll(ref.current, 200)
+ scroll(ref.current, 100)
+
+ expect(result.current.isHeaderVisible).toBe(true)
+ })
+
+ it('keeps the header visible at the top', () => {
+ const ref = createContainerRef({ scrollTop: 100 })
+
+ const { result } = renderHook(() => useHeaderScrollVisibility({ containerRef: ref, enabled: true }))
+ scroll(ref.current, 200)
+ scroll(ref.current, 0)
+
+ expect(result.current.isHeaderVisible).toBe(true)
+ })
+
+ it('forces the header visible near the bottom even while scrolling down', () => {
+ const ref = createContainerRef({ scrollTop: 400, scrollHeight: 1000, clientHeight: 500 })
+
+
+ const { result } = renderHook(() => useHeaderScrollVisibility({ containerRef: ref, enabled: true }))
+ scroll(ref.current, 450)
+ scroll(ref.current, 460)
+
+ expect(result.current.isHeaderVisible).toBe(true)
+ })
+
+ it('ignores small scroll deltas', () => {
+ const ref = createContainerRef({ scrollTop: 100 })
+
+ const { result } = renderHook(() => useHeaderScrollVisibility({ containerRef: ref, enabled: true }))
+ scroll(ref.current, 200)
+ scroll(ref.current, 205)
+
+ expect(result.current.isHeaderVisible).toBe(false)
+ })
+
+ it('ignores scroll events where content height changed', () => {
+ const ref = createContainerRef({ scrollTop: 100, scrollHeight: 1000 })
+
+ const { result } = renderHook(() => useHeaderScrollVisibility({ containerRef: ref, enabled: true }))
+ scroll(ref.current, 200, 1200)
+
+ expect(result.current.isHeaderVisible).toBe(true)
+ })
+
+ it('forces visibility back to true when resetKey changes', () => {
+ const ref = createContainerRef({ scrollTop: 100 })
+
+
+ const { result, rerender } = renderHook(
+ ({ resetKey }) => useHeaderScrollVisibility({ containerRef: ref, enabled: true, resetKey }),
+ { initialProps: { resetKey: 'one' } },
+ )
+ scroll(ref.current, 200)
+
+ expect(result.current.isHeaderVisible).toBe(false)
+
+ rerender({ resetKey: 'two' })
+
+
+ expect(result.current.isHeaderVisible).toBe(true)
+ })
+})
diff --git a/frontend/src/hooks/__tests__/useTallScrollContent.test.tsx b/frontend/src/hooks/__tests__/useTallScrollContent.test.tsx
deleted file mode 100644
index 76e98957..00000000
--- a/frontend/src/hooks/__tests__/useTallScrollContent.test.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import { renderHook, act } from '@testing-library/react'
-import { useTallScrollContent } from '../useTallScrollContent'
-
-let mockObserve: ReturnType