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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
108 changes: 108 additions & 0 deletions apps/client/__tests__/chat-git-worktree-dropdown.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
import type { ButtonHTMLAttributes, ReactNode } from 'react'

import { act } from 'react'
import { createRoot } from 'react-dom/client'
import type { Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { GitWorktreeDropdown } from '#~/components/chat/git-controls/GitWorktreeDropdown'

vi.mock('antd', () => ({
Button: ({ children, type: _type, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { type?: string }) => (
<button {...props}>{children}</button>
),
Dropdown: ({ children, popupRender }: { children: ReactNode; popupRender: () => ReactNode }) => (
<>{children}{popupRender()}</>
),
Switch: () => <button />
}))

vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}))

vi.mock('#~/components/overlay', () => ({
OverlayAction: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
OverlayActionRow: ({ children }: { children: ReactNode }) => <div>{children}</div>,
OverlayPanel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
OverlaySearchRow: () => <input />
}))

vi.mock(
'#~/components/chat/sender/@components/mobile-select-drawer/SenderMobileSelectDrawer',
() => ({
SenderMobileSelectBreadcrumbs: () => null,
SenderMobileSelectDrawer: ({ children }: { children: ReactNode }) => <div>{children}</div>
})
)

let container: HTMLDivElement
let root: Root

const renderDropdown = async (eligible: boolean) => {
await act(async () => {
root.render(
<GitWorktreeDropdown
open
workspace={{
cleanupPolicy: 'retain',
createdAt: 0,
kind: 'shared_workspace',
sessionId: 'session-1',
state: 'ready',
updatedAt: 0,
workspaceFolder: '/workspace/app',
worktreeDerivation: eligible
? { eligible: true }
: { eligible: false, disabledReason: 'external_runtime' }
}}
worktrees={[]}
mode={{
type: 'session',
isBusy: false,
worktreeDerivation: eligible
? { eligible: true }
: { eligible: false, disabledReason: 'external_runtime' },
canTransferToLocal: false,
onCreateManagedWorktree: () => undefined,
onTransferToLocal: () => undefined
}}
onOpenChange={() => undefined}
/>
)
})
}

describe('git worktree dropdown', () => {
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})

afterEach(async () => {
await act(async () => root.unmount())
container.remove()
})

it('keeps Create Worktree mounted and explains external-runtime recovery', async () => {
await renderDropdown(false)

const createWorktree = [...container.querySelectorAll('button')]
.find(button => button.textContent?.includes('chat.sessionWorkspaceMenuCreateWorktree'))

expect(createWorktree).toBeDefined()
expect(createWorktree?.disabled).toBe(true)
expect(createWorktree?.getAttribute('title')).toBe('chat.sessionWorkspaceDerivationDisabled.external_runtime')
expect(container.textContent).toContain('chat.sessionWorkspaceDerivationDisabled.external_runtime')

await renderDropdown(true)

expect(createWorktree?.disabled).toBe(false)
expect(container.textContent).not.toContain('chat.sessionWorkspaceDerivationDisabled.external_runtime')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,7 @@ export function ChatGitControls({
mode={{
type: 'session',
isBusy: git.isBusy,
canCreateManagedWorktree: git.repoState?.available === true &&
git.workspace != null &&
git.workspace.kind !== 'managed_worktree' &&
(git.workspace.worktreePath == null || git.workspace.worktreePath.trim() === ''),
worktreeDerivation: git.workspace?.worktreeDerivation,
canTransferToLocal: git.workspace?.kind === 'managed_worktree',
onCreateManagedWorktree: git.handleCreateManagedWorktree,
onTransferToLocal: git.handleTransferWorkspaceToLocal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { Button, Dropdown, Switch } from 'antd'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'

import type { GitWorktreeSummary, SessionWorkspace } from '@oneworks/types'
import type {
GitWorktreeSummary,
SessionWorkspace,
SessionWorktreeDerivationDisabledReason,
SessionWorktreeDerivationEligibility
} from '@oneworks/types'

import { OverlayAction, OverlayActionRow, OverlayPanel, OverlaySearchRow } from '#~/components/overlay'

Expand All @@ -24,12 +29,20 @@ interface DraftWorktreeMenuMode {
interface SessionWorktreeMenuMode {
type: 'session'
isBusy: boolean
canCreateManagedWorktree: boolean
worktreeDerivation?: SessionWorktreeDerivationEligibility
canTransferToLocal: boolean
onCreateManagedWorktree: () => void
onTransferToLocal: () => void
}

const getWorktreeDerivationDisabledReason = (
reason: SessionWorktreeDerivationDisabledReason | undefined,
t: (key: string) => string
) => {
if (reason == null) return undefined
return t(`chat.sessionWorkspaceDerivationDisabled.${reason}`)
}

const getWorkspaceKindIcon = (kind: SessionWorkspace['kind']) => {
switch (kind) {
case 'managed_worktree':
Expand Down Expand Up @@ -256,16 +269,24 @@ export function GitWorktreeDropdown({
</OverlayAction>
)}

{mode.type === 'session' && mode.canCreateManagedWorktree && (
{mode.type === 'session' && (
<OverlayAction
className='chat-header-git__menu-row'
disabled={mode.isBusy}
disabled={mode.isBusy || mode.worktreeDerivation?.eligible !== true}
title={getWorktreeDerivationDisabledReason(mode.worktreeDerivation?.disabledReason, t)}
onClick={mode.onCreateManagedWorktree}
>
<span className='chat-header-git__menu-row-main'>
<span className='chat-header-git__row-icon material-symbols-rounded'>add</span>
<span className='chat-header-git__menu-row-title'>
{t('chat.sessionWorkspaceMenuCreateWorktree')}
<span className='chat-header-git__row-copy'>
<span className='chat-header-git__menu-row-title'>
{t('chat.sessionWorkspaceMenuCreateWorktree')}
</span>
{mode.worktreeDerivation?.eligible === false && (
<span className='chat-header-git__row-subtitle'>
{getWorktreeDerivationDisabledReason(mode.worktreeDerivation.disabledReason, t)}
</span>
)}
</span>
</span>
</OverlayAction>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function useChatGitControls(sessionId: string) {
const { data: workspaceData, mutate: mutateWorkspaceData } = useSWR<{ workspace: SessionWorkspace }>(
['session-workspace', sessionId],
() => getSessionWorkspace(sessionId),
{ revalidateOnFocus: false }
{ refreshInterval: 5_000, revalidateOnFocus: false }
)
const { data: repoState, mutate: mutateRepoState } = useSWR<GitRepositoryState>(
['session-git-state', sessionId],
Expand Down
9 changes: 9 additions & 0 deletions apps/client/src/resources/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2784,6 +2784,15 @@
"sessionWorkspaceMenuLaunchInWorktree": "Start in worktree mode",
"sessionWorkspaceMenuTransferToLocal": "Transfer to local",
"sessionWorkspaceMenuCreateWorktree": "Create worktree",
"sessionWorkspaceDerivationDisabled": {
"already_managed_worktree": "This session already uses a managed worktree.",
"workspace_unavailable": "Wait for the session workspace to become ready.",
"external_runtime": "External-runtime sessions cannot change worktrees.",
"not_repository": "Open a session in a Git repository to create a worktree.",
"git_not_installed": "Install Git on the server running this workspace.",
"repository_unavailable": "The server cannot inspect this workspace's repository.",
"dirty_worktree": "Commit, stash, or discard local changes before creating a worktree."
},
"sessionWorkspaceDraftCreateWorktreeEnabled": "New sessions get an isolated managed worktree.",
"sessionWorkspaceDraftCreateWorktreeDisabled": "New sessions reuse the shared workspace directly.",
"sessionWorkspaceDraftCreateBranchLabel": "New branch: {{branch}}",
Expand Down
9 changes: 9 additions & 0 deletions apps/client/src/resources/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -2785,6 +2785,15 @@
"sessionWorkspaceMenuLaunchInWorktree": "以工作树模式启动",
"sessionWorkspaceMenuTransferToLocal": "转移到本地",
"sessionWorkspaceMenuCreateWorktree": "创建工作树",
"sessionWorkspaceDerivationDisabled": {
"already_managed_worktree": "当前会话已经使用托管工作树。",
"workspace_unavailable": "请等待会话工作区就绪后再试。",
"external_runtime": "外部运行端会话不能切换工作树。",
"not_repository": "请在 Git 项目中打开会话后再创建工作树。",
"git_not_installed": "请先在运行此工作区的服务端安装 Git。",
"repository_unavailable": "服务端暂时无法检查此工作区的 Git 仓库。",
"dirty_worktree": "请先提交、暂存或丢弃本地改动,再创建工作树。"
},
"sessionWorkspaceDraftCreateWorktreeEnabled": "新会话会自动拿到一个隔离的托管 worktree。",
"sessionWorkspaceDraftCreateWorktreeDisabled": "新会话会直接复用当前共享工作区。",
"sessionWorkspaceDraftCreateBranchLabel": "新分支:{{branch}}",
Expand Down
64 changes: 62 additions & 2 deletions apps/server/__tests__/routes/sessions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ import {
updateAndNotifySession
} from '#~/services/session/index.js'
import { notifySessionUpdated } from '#~/services/session/runtime.js'
import { provisionSessionWorkspace, resolveSessionWorkspace } from '#~/services/session/workspace.js'
import {
createSessionManagedWorktree,
provisionSessionWorkspace,
resolveSessionWorkspace,
resolveSessionWorkspaceWithDerivationEligibility,
transferSessionWorkspaceToLocal
} from '#~/services/session/workspace.js'
import { disposeTerminalSession } from '#~/services/terminal/index.js'

vi.mock('#~/db/index.js', () => ({
Expand Down Expand Up @@ -92,10 +98,11 @@ vi.mock('#~/services/session/runtime.js', () => ({
}))

vi.mock('#~/services/session/workspace.js', () => ({
createSessionManagedWorktree: vi.fn(),
createSessionManagedWorktree: vi.fn().mockResolvedValue(undefined),
deleteSessionWorkspace: vi.fn(),
provisionSessionWorkspace: vi.fn(),
resolveSessionWorkspace: vi.fn(),
resolveSessionWorkspaceWithDerivationEligibility: vi.fn(),
resolveSessionWorkspaceFolder: vi.fn(),
transferSessionWorkspaceToLocal: vi.fn()
}))
Expand Down Expand Up @@ -131,6 +138,11 @@ describe('sessionsRouter', () => {
sessionId: 'session-branch',
workspaceFolder: '/workspace/root'
} as any)
vi.mocked(resolveSessionWorkspaceWithDerivationEligibility).mockResolvedValue({
sessionId: 'session-branch',
workspaceFolder: '/workspace/root',
worktreeDerivation: { eligible: true }
} as any)
})

it('returns a single session by id', () => {
Expand All @@ -155,6 +167,54 @@ describe('sessionsRouter', () => {
expect(ctx.body).toEqual({ session })
})

it('returns worktree derivation eligibility with the session workspace', async () => {
const handleGetWorkspace = findRouteHandler('/:id/workspace', 'GET')
const ctx = {
params: { id: 'session-derivation' },
body: undefined
}

await handleGetWorkspace(ctx)

expect(resolveSessionWorkspaceWithDerivationEligibility).toHaveBeenCalledWith('session-derivation')
expect(ctx.body).toEqual({
workspace: expect.objectContaining({ worktreeDerivation: { eligible: true } })
})
})

it('does not terminate an already-managed session when worktree derivation is rejected', async () => {
const error = Object.assign(new Error('already managed'), {
code: 'session_workspace_derivation_unavailable'
})
vi.mocked(createSessionManagedWorktree).mockRejectedValueOnce(error)
const handleCreateWorktree = findRouteHandler('/:id/workspace/create-worktree', 'POST')
const ctx = {
params: { id: 'session-managed' },
body: undefined
}

await expect(handleCreateWorktree(ctx)).rejects.toBe(error)

expect(killSession).not.toHaveBeenCalled()
expect(disposeTerminalSession).not.toHaveBeenCalled()
})

it('returns current worktree derivation eligibility after transferring to local', async () => {
const handleTransferToLocal = findRouteHandler('/:id/workspace/transfer-local', 'POST')
const ctx = {
params: { id: 'session-transfer' },
body: undefined
}

await handleTransferToLocal(ctx)

expect(transferSessionWorkspaceToLocal).toHaveBeenCalledWith('session-transfer')
expect(resolveSessionWorkspaceWithDerivationEligibility).toHaveBeenCalledWith('session-transfer')
expect(ctx.body).toEqual({
workspace: expect.objectContaining({ worktreeDerivation: { eligible: true } })
})
})

it('triggers native project history import', async () => {
const result = {
importedEvents: 2,
Expand Down
Loading
Loading