feat(intelligence): worktree isolation frontend + RPC wiring (#3376) (#3696)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-06-15 19:41:26 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 6c89396e6f
commit bf6fd85006
40 changed files with 1899 additions and 2 deletions
@@ -0,0 +1,67 @@
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { worktreeApi, type WorktreeListView } from '../../services/api/worktreeApi';
import IntelligenceWorktreesTab from './IntelligenceWorktreesTab';
vi.mock('../../services/api/worktreeApi', () => ({
worktreeApi: { list: vi.fn(), diff: vi.fn(), remove: vi.fn() },
}));
vi.mock('../../utils/openUrl', () => ({ revealPath: vi.fn().mockResolvedValue(undefined) }));
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
const mockList = vi.mocked(worktreeApi.list);
function view(overrides?: Partial<WorktreeListView>): WorktreeListView {
return {
worktrees: [
{
path: '/r/.claude/worktrees/worker-a',
branch: 'worker/a',
isDirty: true,
changedFiles: ['src/lib.rs', 'a.rs'],
},
],
overlaps: [],
...overrides,
};
}
describe('IntelligenceWorktreesTab', () => {
beforeEach(() => {
mockList.mockReset();
});
it('renders empty state when there are no worktrees', async () => {
mockList.mockResolvedValue({ worktrees: [], overlaps: [] });
render(<IntelligenceWorktreesTab />);
await waitFor(() => expect(screen.getByText('worktree.panel.empty')).toBeInTheDocument());
});
it('renders a worktree row with branch, dirty badge and changed files', async () => {
mockList.mockResolvedValue(view());
render(<IntelligenceWorktreesTab />);
await waitFor(() => expect(screen.getByTestId('worktree-row')).toBeInTheDocument());
expect(screen.getByText('worker/a')).toBeInTheDocument();
expect(screen.getByText('worktree.dirty')).toBeInTheDocument();
expect(screen.getByText('src/lib.rs')).toBeInTheDocument();
expect(screen.getByTestId('worktree-actions')).toBeInTheDocument();
});
it('renders an overlap banner when workers touched the same file', async () => {
mockList.mockResolvedValue(
view({ overlaps: [{ file: 'src/lib.rs', branches: ['worker/a', 'worker/b'] }] })
);
render(<IntelligenceWorktreesTab />);
await waitFor(() => expect(screen.getByTestId('worktree-overlaps')).toBeInTheDocument());
expect(screen.getByText('worktree.panel.overlapsTitle')).toBeInTheDocument();
});
it('surfaces a load error', async () => {
mockList.mockRejectedValue(new Error('boom'));
render(<IntelligenceWorktreesTab />);
await waitFor(() =>
expect(screen.getByText(/worktree.panel.failedToLoad/)).toBeInTheDocument()
);
});
});
@@ -0,0 +1,169 @@
/**
* IntelligenceWorktreesTab — the isolated-worktree command-center surface (#3376).
*
* Reads `openhuman.worktree_list` (via {@link worktreeApi}) and renders every
* managed worker `git worktree` (those under `<repo>/.claude/worktrees`) with
* its branch, dirty status, and changed files, plus per-row open / diff /
* remove actions. A cross-worktree overlap banner warns when two workers
* changed the same file so the user reconciles before merging.
*
* Mirrors {@link IntelligenceAgentWorkTab}'s mount pattern (mountedRef + a 0ms
* `setTimeout` so the first paint shows the loading state). Removing a worktree
* refetches the list so the row drops and overlaps recompute.
*/
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { worktreeApi, type WorktreeListView } from '../../services/api/worktreeApi';
import { basename } from '../../utils/pathUtils';
import WorktreeActions from '../worktree/WorktreeActions';
const log = debug('intelligence:worktrees');
export default function IntelligenceWorktreesTab() {
const { t } = useT();
const [data, setData] = useState<WorktreeListView | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const mountedRef = useRef(true);
const fetchWorktrees = useCallback(async () => {
log('fetchWorktrees: entry');
setError(null);
try {
const view = await worktreeApi.list();
if (mountedRef.current) {
setData(view);
log('fetchWorktrees: done count=%d', view.worktrees.length);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('fetchWorktrees: error %s', msg);
if (mountedRef.current) setError(msg);
} finally {
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => {
mountedRef.current = true;
const handle = window.setTimeout(() => void fetchWorktrees(), 0);
return () => {
window.clearTimeout(handle);
mountedRef.current = false;
};
}, [fetchWorktrees]);
const onRemoved = useCallback(() => {
void fetchWorktrees();
}, [fetchWorktrees]);
if (loading) {
return (
<div className="flex items-center justify-center py-10 text-stone-400 dark:text-neutral-500">
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
<span className="text-sm">{t('worktree.panel.loading')}</span>
</div>
);
}
if (error) {
return (
<div className="rounded-xl border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
{t('worktree.panel.failedToLoad')}: {error}
</div>
);
}
if (!data || data.worktrees.length === 0) {
return (
<div className="space-y-4">
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('worktree.panel.subtitle')}
</p>
<div className="rounded-xl border border-dashed border-stone-200 py-10 text-center text-sm text-stone-400 dark:border-neutral-800 dark:text-neutral-500">
{t('worktree.panel.empty')}
</div>
</div>
);
}
return (
<div className="space-y-4">
<p className="text-xs text-stone-400 dark:text-neutral-500">{t('worktree.panel.subtitle')}</p>
{data.overlaps.length > 0 ? (
<div
className="space-y-1 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
data-testid="worktree-overlaps">
<p className="font-medium">{t('worktree.panel.overlapsTitle')}</p>
<p className="text-amber-700 dark:text-amber-300">{t('worktree.panel.overlapHint')}</p>
<ul className="mt-1 space-y-0.5">
{data.overlaps.map(o => (
<li key={o.file} className="font-mono">
{o.file}
<span className="ml-1 font-sans text-amber-600 dark:text-amber-400">
({o.branches.join(', ')})
</span>
</li>
))}
</ul>
</div>
) : null}
<ul className="divide-y divide-stone-100 overflow-hidden rounded-xl border border-stone-200 bg-white dark:divide-neutral-800 dark:border-neutral-800 dark:bg-neutral-900">
{data.worktrees.map(wt => (
<li key={wt.path} className="space-y-2 p-3" data-testid="worktree-row">
<div className="flex flex-wrap items-center gap-2">
<span
className="truncate text-sm font-medium text-stone-800 dark:text-neutral-100"
title={wt.path}>
{basename(wt.path)}
</span>
{wt.branch ? (
<span className="rounded-md border border-stone-200 px-1.5 py-0.5 font-mono text-[10px] text-stone-500 dark:border-neutral-700 dark:text-neutral-400">
{wt.branch}
</span>
) : null}
{wt.isDirty ? (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-500/15 dark:text-amber-300">
{t('worktree.dirty')}
</span>
) : (
<span className="rounded-full bg-sage-100 px-2 py-0.5 text-[10px] font-medium text-sage-700 dark:bg-sage-500/15 dark:text-sage-300">
{t('worktree.clean')}
</span>
)}
{wt.changedFiles.length > 0 ? (
<span className="text-[11px] text-stone-400 dark:text-neutral-500">
{wt.changedFiles.length}{' '}
{wt.changedFiles.length === 1
? t('worktree.changedFile')
: t('worktree.changedFiles')}
</span>
) : null}
</div>
{wt.changedFiles.length > 0 ? (
<ul className="ml-1 space-y-0.5 font-mono text-[11px] text-stone-500 dark:text-neutral-400">
{wt.changedFiles.slice(0, 8).map(f => (
<li key={f} className="truncate" title={f}>
{f}
</li>
))}
{wt.changedFiles.length > 8 ? (
<li className="text-stone-400 dark:text-neutral-500">
+{wt.changedFiles.length - 8}
</li>
) : null}
</ul>
) : null}
<WorktreeActions path={wt.path} isDirty={wt.isDirty} onRemoved={onRemoved} />
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,95 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { worktreeApi } from '../../services/api/worktreeApi';
import { revealPath } from '../../utils/openUrl';
import WorktreeActions from './WorktreeActions';
vi.mock('../../services/api/worktreeApi', () => ({
worktreeApi: { diff: vi.fn(), remove: vi.fn() },
}));
vi.mock('../../utils/openUrl', () => ({ revealPath: vi.fn().mockResolvedValue(undefined) }));
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
const mockDiff = vi.mocked(worktreeApi.diff);
const mockRemove = vi.mocked(worktreeApi.remove);
const mockReveal = vi.mocked(revealPath);
const PATH = '/r/.claude/worktrees/worker-a';
describe('WorktreeActions', () => {
beforeEach(() => {
mockDiff.mockReset();
mockRemove.mockReset().mockResolvedValue(true);
mockReveal.mockReset().mockResolvedValue(undefined);
});
it('open reveals the path in the OS file manager', async () => {
const user = userEvent.setup();
render(<WorktreeActions path={PATH} />);
await user.click(screen.getByTestId('worktree-open'));
expect(mockReveal).toHaveBeenCalledWith(PATH);
});
it('diff fetches once and renders the summary, toggling closed on second click', async () => {
mockDiff.mockResolvedValue(' src/a.rs | 2 +-');
const user = userEvent.setup();
render(<WorktreeActions path={PATH} />);
await user.click(screen.getByTestId('worktree-diff'));
await waitFor(() =>
expect(screen.getByTestId('worktree-diff-output')).toHaveTextContent('src/a.rs')
);
expect(mockDiff).toHaveBeenCalledTimes(1);
await user.click(screen.getByTestId('worktree-diff'));
expect(screen.queryByTestId('worktree-diff-output')).toBeNull();
});
it('removes a clean worktree in one click (force=false)', async () => {
const onRemoved = vi.fn();
const user = userEvent.setup();
render(<WorktreeActions path={PATH} isDirty={false} onRemoved={onRemoved} />);
await user.click(screen.getByTestId('worktree-remove'));
await waitFor(() => expect(mockRemove).toHaveBeenCalledWith(PATH, false));
expect(onRemoved).toHaveBeenCalledWith(PATH);
});
it('a dirty worktree requires explicit confirmation before removal', async () => {
const onRemoved = vi.fn();
const user = userEvent.setup();
render(<WorktreeActions path={PATH} isDirty onRemoved={onRemoved} />);
await user.click(screen.getByTestId('worktree-remove'));
// No removal yet — a confirm row appears instead.
expect(mockRemove).not.toHaveBeenCalled();
expect(screen.getByTestId('worktree-remove-confirm')).toBeInTheDocument();
// Discard & remove forces.
await user.click(screen.getByTestId('worktree-remove-confirm-yes'));
await waitFor(() => expect(mockRemove).toHaveBeenCalledWith(PATH, true));
expect(onRemoved).toHaveBeenCalledWith(PATH);
});
it('resets the removing state after success when no onRemoved parent drops the row', async () => {
// Inline timeline use (ToolTimelineBlock) renders without onRemoved, so the
// component is never unmounted by a parent — it must reset its own state or
// the button stays stuck on "worktree.removing" with disabled forever.
const user = userEvent.setup();
render(<WorktreeActions path={PATH} isDirty={false} />);
await user.click(screen.getByTestId('worktree-remove'));
await waitFor(() => expect(mockRemove).toHaveBeenCalledWith(PATH, false));
await waitFor(() => {
const btn = screen.getByTestId('worktree-remove');
expect(btn).toHaveTextContent('worktree.action.remove');
expect(btn).not.toBeDisabled();
});
});
it('preserve cancels the dirty-removal confirm without removing', async () => {
const user = userEvent.setup();
render(<WorktreeActions path={PATH} isDirty />);
await user.click(screen.getByTestId('worktree-remove'));
await user.click(screen.getByTestId('worktree-preserve'));
expect(mockRemove).not.toHaveBeenCalled();
expect(screen.queryByTestId('worktree-remove-confirm')).toBeNull();
expect(screen.getByTestId('worktree-remove')).toBeInTheDocument();
});
});
@@ -0,0 +1,196 @@
/**
* WorktreeActions — open / diff / remove affordances for one isolated worker
* `git worktree` (#3376). Shared between the inline subagent row (compact) in
* the conversation timeline and the Worktrees command-center panel (full).
*
* Safety contract (acceptance criterion of #3376): a **clean** worktree removes
* in one click; a **dirty** worktree requires an explicit user decision — the
* Remove button expands into a confirm row offering "Discard & remove" (force)
* or "Preserve" (cancel, keep the worktree + its `worker/*` branch). The core
* itself also refuses a dirty removal without `force`, so this is defense in
* depth, not the only guard.
*/
import debug from 'debug';
import { useCallback, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { worktreeApi } from '../../services/api/worktreeApi';
import { revealPath } from '../../utils/openUrl';
const log = debug('worktree:actions');
export interface WorktreeActionsProps {
/** Absolute worktree checkout path. */
path: string;
/** Whether the worktree has uncommitted changes. */
isDirty?: boolean;
/** Compact styling for the inline subagent row (vs the full panel row). */
compact?: boolean;
/** Called after a successful remove so the parent can refetch / drop the row. */
onRemoved?: (path: string) => void;
}
const btnBase =
'rounded-md border px-2 py-0.5 font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50';
const btnNeutral =
'border-stone-200 text-stone-600 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800';
const btnDanger =
'border-coral-200 text-coral-600 hover:bg-coral-50 dark:border-coral-500/40 dark:text-coral-300 dark:hover:bg-coral-500/10';
export default function WorktreeActions({
path,
isDirty,
compact,
onRemoved,
}: WorktreeActionsProps) {
const { t } = useT();
const [diffOpen, setDiffOpen] = useState(false);
const [diffText, setDiffText] = useState<string | null>(null);
const [diffLoading, setDiffLoading] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null);
const sizeText = compact ? 'text-[10px]' : 'text-xs';
const handleOpen = useCallback(() => {
log('open path=%s', path);
void revealPath(path).catch(err => {
log('open failed %O', err);
setError(err instanceof Error ? err.message : String(err));
});
}, [path]);
const handleDiff = useCallback(async () => {
if (diffOpen) {
setDiffOpen(false);
return;
}
setDiffOpen(true);
setError(null);
// Fetch once; cache for subsequent toggles.
if (diffText !== null) return;
setDiffLoading(true);
try {
const summary = await worktreeApi.diff(path);
setDiffText(summary);
log('diff loaded path=%s chars=%d', path, summary.length);
} catch (err) {
log('diff failed %O', err);
setError(t('worktree.diffFailed'));
setDiffOpen(false);
} finally {
setDiffLoading(false);
}
}, [diffOpen, diffText, path, t]);
const doRemove = useCallback(
async (force: boolean) => {
setRemoving(true);
setError(null);
try {
await worktreeApi.remove(path, force);
log('removed path=%s force=%s', path, force);
// The parent (e.g. the Worktrees panel) drops the row on success and
// this component unmounts. But inline in the chat timeline
// (`ToolTimelineBlock`) there is no `onRemoved`, so nothing unmounts
// us — reset local state here or the row stays stuck on "Removing…"
// with the button disabled forever even though the checkout is gone.
if (onRemoved) {
onRemoved(path);
} else {
setRemoving(false);
setConfirmRemove(false);
}
} catch (err) {
log('remove failed %O', err);
setError(t('worktree.removeFailed'));
setRemoving(false);
setConfirmRemove(false);
}
},
[path, onRemoved, t]
);
const handleRemoveClick = useCallback(() => {
// Dirty worktrees must not be removed without an explicit choice.
if (isDirty) {
setConfirmRemove(true);
return;
}
void doRemove(false);
}, [isDirty, doRemove]);
return (
<div className={`space-y-1.5 ${sizeText}`} data-testid="worktree-actions">
{!confirmRemove ? (
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={handleOpen}
className={`${btnBase} ${btnNeutral}`}
data-testid="worktree-open">
{t('worktree.action.open')}
</button>
<button
type="button"
onClick={() => void handleDiff()}
className={`${btnBase} ${btnNeutral}`}
data-testid="worktree-diff">
{diffOpen ? t('worktree.action.hideDiff') : t('worktree.action.diff')}
</button>
<button
type="button"
onClick={handleRemoveClick}
disabled={removing}
className={`${btnBase} ${btnDanger}`}
data-testid="worktree-remove">
{removing ? t('worktree.removing') : t('worktree.action.remove')}
</button>
</div>
) : (
<div
className="space-y-1.5 rounded-md border border-coral-200 bg-coral-50 p-2 dark:border-coral-500/40 dark:bg-coral-500/10"
data-testid="worktree-remove-confirm">
<p className="text-coral-700 dark:text-coral-300">{t('worktree.dirtyConfirm')}</p>
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={() => void doRemove(true)}
disabled={removing}
className={`${btnBase} ${btnDanger}`}
data-testid="worktree-remove-confirm-yes">
{removing ? t('worktree.removing') : t('worktree.action.removeAnyway')}
</button>
<button
type="button"
onClick={() => setConfirmRemove(false)}
disabled={removing}
className={`${btnBase} ${btnNeutral}`}
data-testid="worktree-preserve">
{t('worktree.action.preserve')}
</button>
</div>
</div>
)}
{diffOpen ? (
<pre
className="max-h-48 overflow-auto rounded-md border border-stone-200 bg-stone-50 p-2 font-mono text-[10px] leading-snug text-stone-700 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-300"
data-testid="worktree-diff-output">
{diffLoading
? t('worktree.diffLoading')
: diffText && diffText.trim().length > 0
? diffText
: t('worktree.diffEmpty')}
</pre>
) : null}
{error ? (
<p className="text-coral-600 dark:text-coral-300" data-testid="worktree-error">
{error}
</p>
) : null}
</div>
);
}
+29
View File
@@ -409,6 +409,9 @@ const messages: TranslationMap = {
'memory.tab.agentWork': 'عمل الوكيل',
'memory.tab.agentWorkDescription':
'مركز قيادة لكل تشغيل وكيل في الخلفية — مُجمّع حسب ما يحتاج إلى مُدخلاتك، وما يعمل الآن، وما اكتمل.',
'memory.tab.worktrees': 'أشجار العمل',
'memory.tab.worktreesDescription':
'أشجار عمل git معزولة من عُمّال الوكلاء المتوازين — افحص التغييرات، أو افتحها، أو نظّفها.',
'memory.tab.teams': 'الفِرَق',
'memory.tab.teamsDescription':
'فِرَق وكلاء منسَّقة — مَن يملك كل مهمة، وما المحظور، وما الذي يقوله الزملاء لبعضهم البعض.',
@@ -2947,6 +2950,32 @@ const messages: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': 'عنصر فرعي لسير العمل',
'intelligence.agentWork.openThread': 'فتح المحادثة',
'intelligence.agentWork.openWorker': 'فتح العامل',
'worktree.label': 'شجرة العمل',
'worktree.dirty': 'تغييرات غير مُلتزمة',
'worktree.clean': 'نظيفة',
'worktree.changedFile': 'ملف مُعدّل',
'worktree.changedFiles': 'ملفات مُعدّلة',
'worktree.removing': 'جارٍ الإزالة…',
'worktree.removeFailed': 'فشل إزالة شجرة العمل',
'worktree.diffFailed': 'فشل تحميل الفرق',
'worktree.diffLoading': 'جارٍ تحميل الفرق…',
'worktree.diffEmpty': 'لا توجد تغييرات مقارنةً بـ HEAD.',
'worktree.dirtyConfirm':
'تحتوي شجرة العمل هذه على تغييرات غير مُلتزمة. إزالتها تتجاهل ذلك العمل. يتم الاحتفاظ بفرع العامل.',
'worktree.action.open': 'فتح المجلد',
'worktree.action.diff': 'الفرق',
'worktree.action.hideDiff': 'إخفاء الفرق',
'worktree.action.remove': 'إزالة',
'worktree.action.removeAnyway': 'تجاهل وإزالة',
'worktree.action.preserve': 'الإبقاء',
'worktree.panel.subtitle': 'أشجار عمل git معزولة أنشأها عُمّال الوكلاء المتوازون.',
'worktree.panel.loading': 'جارٍ تحميل أشجار العمل…',
'worktree.panel.failedToLoad': 'فشل تحميل أشجار العمل',
'worktree.panel.empty':
'لا توجد أشجار عمل معزولة. ستظهر هنا العُمّال المتوازون ذوو عزل شجرة العمل.',
'worktree.panel.overlapsTitle': 'تغييرات متداخلة',
'worktree.panel.overlapHint':
'تم تعديل هذه الملفات بواسطة أكثر من شجرة عمل — وفّق بينها قبل الدمج.',
'intelligence.agentWork.action.stop': 'إيقاف',
'intelligence.agentWork.action.retry': 'إعادة المحاولة',
'intelligence.agentWork.action.continue': 'متابعة',
+29
View File
@@ -415,6 +415,9 @@ const messages: TranslationMap = {
'memory.tab.agentWork': 'এজেন্ট কাজ',
'memory.tab.agentWorkDescription':
'প্রতিটি ব্যাকগ্রাউন্ড এজেন্ট রানের জন্য একটি কমান্ড সেন্টার — আপনার ইনপুট প্রয়োজন এমন, চলমান এবং সম্পন্ন অনুযায়ী গোষ্ঠীবদ্ধ।',
'memory.tab.worktrees': 'ওয়ার্কট্রি',
'memory.tab.worktreesDescription':
'সমান্তরাল এজেন্ট ওয়ার্কারদের থেকে বিচ্ছিন্ন git ওয়ার্কট্রি — পরিবর্তন পরিদর্শন করুন, খুলুন বা পরিষ্কার করুন।',
'memory.tab.teams': 'টিম',
'memory.tab.teamsDescription':
'সমন্বিত এজেন্ট টিম — কে প্রতিটি কাজের মালিক, কী আটকে আছে, এবং সতীর্থরা একে অপরকে কী বলছে।',
@@ -3009,6 +3012,32 @@ const messages: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': 'ওয়ার্কফ্লো চাইল্ড',
'intelligence.agentWork.openThread': 'থ্রেড খুলুন',
'intelligence.agentWork.openWorker': 'ওয়ার্কার খুলুন',
'worktree.label': 'ওয়ার্কট্রি',
'worktree.dirty': 'কমিট না-করা পরিবর্তন',
'worktree.clean': 'পরিচ্ছন্ন',
'worktree.changedFile': 'পরিবর্তিত ফাইল',
'worktree.changedFiles': 'পরিবর্তিত ফাইল',
'worktree.removing': 'সরানো হচ্ছে…',
'worktree.removeFailed': 'ওয়ার্কট্রি সরাতে ব্যর্থ',
'worktree.diffFailed': 'ডিফ লোড করতে ব্যর্থ',
'worktree.diffLoading': 'ডিফ লোড হচ্ছে…',
'worktree.diffEmpty': 'HEAD-এর তুলনায় কোনো পরিবর্তন নেই।',
'worktree.dirtyConfirm':
'এই ওয়ার্কট্রিতে কমিট না-করা পরিবর্তন আছে। এটি সরালে সেই কাজ বাতিল হয়ে যাবে। ওয়ার্কার ব্রাঞ্চ রাখা হবে।',
'worktree.action.open': 'ফোল্ডার খুলুন',
'worktree.action.diff': 'ডিফ',
'worktree.action.hideDiff': 'ডিফ লুকান',
'worktree.action.remove': 'সরান',
'worktree.action.removeAnyway': 'বাতিল করে সরান',
'worktree.action.preserve': 'রেখে দিন',
'worktree.panel.subtitle': 'সমান্তরাল এজেন্ট ওয়ার্কারদের তৈরি করা বিচ্ছিন্ন git ওয়ার্কট্রি।',
'worktree.panel.loading': 'ওয়ার্কট্রি লোড হচ্ছে…',
'worktree.panel.failedToLoad': 'ওয়ার্কট্রি লোড করতে ব্যর্থ',
'worktree.panel.empty':
'কোনো বিচ্ছিন্ন ওয়ার্কট্রি নেই। ওয়ার্কট্রি বিচ্ছিন্নকরণসহ সমান্তরাল ওয়ার্কাররা এখানে প্রদর্শিত হবে।',
'worktree.panel.overlapsTitle': 'ওভারল্যাপিং পরিবর্তন',
'worktree.panel.overlapHint':
'এই ফাইলগুলো একাধিক ওয়ার্কট্রি দ্বারা পরিবর্তিত হয়েছে — মার্জ করার আগে সমন্বয় করুন।',
'intelligence.agentWork.action.stop': 'থামান',
'intelligence.agentWork.action.retry': 'আবার চেষ্টা করুন',
'intelligence.agentWork.action.continue': 'চালিয়ে যান',
+30
View File
@@ -427,6 +427,9 @@ const messages: TranslationMap = {
'memory.tab.agentWork': 'Agentenarbeit',
'memory.tab.agentWorkDescription':
'Eine Kommandozentrale für jeden Hintergrund-Agentenlauf — gruppiert danach, was deine Eingabe braucht, was läuft und was abgeschlossen ist.',
'memory.tab.worktrees': 'Worktrees',
'memory.tab.worktreesDescription':
'Isolierte Git-Worktrees von parallelen Agenten-Workern — Änderungen prüfen, öffnen oder aufräumen.',
'memory.tab.teams': 'Teams',
'memory.tab.teamsDescription':
'Koordinierte Agententeams — wer für welche Aufgabe zuständig ist, was blockiert ist und was sich die Teammitglieder gegenseitig mitteilen.',
@@ -3081,6 +3084,33 @@ const messages: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': 'Workflow-Unterelement',
'intelligence.agentWork.openThread': 'Thread öffnen',
'intelligence.agentWork.openWorker': 'Worker öffnen',
'worktree.label': 'Worktree',
'worktree.dirty': 'Nicht committete Änderungen',
'worktree.clean': 'Sauber',
'worktree.changedFile': 'geänderte Datei',
'worktree.changedFiles': 'geänderte Dateien',
'worktree.removing': 'Wird entfernt…',
'worktree.removeFailed': 'Worktree konnte nicht entfernt werden',
'worktree.diffFailed': 'Diff konnte nicht geladen werden',
'worktree.diffLoading': 'Diff wird geladen…',
'worktree.diffEmpty': 'Keine Änderungen gegenüber HEAD.',
'worktree.dirtyConfirm':
'Dieser Worktree hat nicht committete Änderungen. Beim Entfernen geht diese Arbeit verloren. Der Worker-Branch bleibt erhalten.',
'worktree.action.open': 'Ordner öffnen',
'worktree.action.diff': 'Diff',
'worktree.action.hideDiff': 'Diff ausblenden',
'worktree.action.remove': 'Entfernen',
'worktree.action.removeAnyway': 'Verwerfen & entfernen',
'worktree.action.preserve': 'Beibehalten',
'worktree.panel.subtitle':
'Isolierte Git-Worktrees, die von parallelen Agenten-Workern erstellt wurden.',
'worktree.panel.loading': 'Worktrees werden geladen…',
'worktree.panel.failedToLoad': 'Worktrees konnten nicht geladen werden',
'worktree.panel.empty':
'Keine isolierten Worktrees. Parallele Worker mit Worktree-Isolation erscheinen hier.',
'worktree.panel.overlapsTitle': 'Überlappende Änderungen',
'worktree.panel.overlapHint':
'Diese Dateien wurden von mehr als einem Worktree geändert — vor dem Mergen abgleichen.',
'intelligence.agentWork.action.stop': 'Stoppen',
'intelligence.agentWork.action.retry': 'Wiederholen',
'intelligence.agentWork.action.continue': 'Fortsetzen',
+30
View File
@@ -455,6 +455,9 @@ const en: TranslationMap = {
'memory.tab.agentWork': 'Agent Work',
'memory.tab.agentWorkDescription':
'A command center for every background agent run — grouped by what needs your input, what is working, and what has finished.',
'memory.tab.worktrees': 'Worktrees',
'memory.tab.worktreesDescription':
'Isolated git worktrees from parallel agent workers — inspect changes, open them, or clean them up.',
'memory.tab.teams': 'Teams',
'memory.tab.teamsDescription':
'Coordinated agent teams — who owns each task, what is blocked, and what the teammates are saying to each other.',
@@ -3551,6 +3554,33 @@ const en: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': 'Workflow child',
'intelligence.agentWork.openThread': 'Open thread',
'intelligence.agentWork.openWorker': 'Open worker',
// Worktree manager (#3376) — isolated worker git worktrees
'worktree.label': 'Worktree',
'worktree.dirty': 'Uncommitted changes',
'worktree.clean': 'Clean',
'worktree.changedFile': 'changed file',
'worktree.changedFiles': 'changed files',
'worktree.removing': 'Removing…',
'worktree.removeFailed': 'Failed to remove worktree',
'worktree.diffFailed': 'Failed to load diff',
'worktree.diffLoading': 'Loading diff…',
'worktree.diffEmpty': 'No changes vs HEAD.',
'worktree.dirtyConfirm':
'This worktree has uncommitted changes. Removing it discards that work. The worker branch is kept.',
'worktree.action.open': 'Open folder',
'worktree.action.diff': 'Diff',
'worktree.action.hideDiff': 'Hide diff',
'worktree.action.remove': 'Remove',
'worktree.action.removeAnyway': 'Discard & remove',
'worktree.action.preserve': 'Preserve',
'worktree.panel.subtitle': 'Isolated git worktrees created by parallel agent workers.',
'worktree.panel.loading': 'Loading worktrees…',
'worktree.panel.failedToLoad': 'Failed to load worktrees',
'worktree.panel.empty':
'No isolated worktrees. Parallel workers with worktree isolation will appear here.',
'worktree.panel.overlapsTitle': 'Overlapping changes',
'worktree.panel.overlapHint':
'These files were changed by more than one worktree — reconcile before merging.',
'intelligence.agentWork.action.stop': 'Stop',
'intelligence.agentWork.action.retry': 'Retry',
'intelligence.agentWork.action.continue': 'Continue',
+30
View File
@@ -427,6 +427,9 @@ const messages: TranslationMap = {
'memory.tab.agentWork': 'Trabajo del agente',
'memory.tab.agentWorkDescription':
'Un centro de mando para cada ejecución de agente en segundo plano, agrupado según lo que necesita tu intervención, lo que está en curso y lo que ha terminado.',
'memory.tab.worktrees': 'Worktrees',
'memory.tab.worktreesDescription':
'Worktrees de git aislados de los workers de agentes en paralelo: inspecciona los cambios, ábrelos o límpialos.',
'memory.tab.teams': 'Equipos',
'memory.tab.teamsDescription':
'Equipos de agentes coordinados: quién se encarga de cada tarea, qué está bloqueado y qué se dicen los compañeros entre sí.',
@@ -3064,6 +3067,33 @@ const messages: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': 'Hijo de flujo de trabajo',
'intelligence.agentWork.openThread': 'Abrir hilo',
'intelligence.agentWork.openWorker': 'Abrir trabajador',
'worktree.label': 'Worktree',
'worktree.dirty': 'Cambios sin confirmar',
'worktree.clean': 'Limpio',
'worktree.changedFile': 'archivo modificado',
'worktree.changedFiles': 'archivos modificados',
'worktree.removing': 'Eliminando…',
'worktree.removeFailed': 'No se pudo eliminar el worktree',
'worktree.diffFailed': 'No se pudo cargar el diff',
'worktree.diffLoading': 'Cargando diff…',
'worktree.diffEmpty': 'Sin cambios respecto a HEAD.',
'worktree.dirtyConfirm':
'Este worktree tiene cambios sin confirmar. Eliminarlo descarta ese trabajo. La rama del worker se conserva.',
'worktree.action.open': 'Abrir carpeta',
'worktree.action.diff': 'Diff',
'worktree.action.hideDiff': 'Ocultar diff',
'worktree.action.remove': 'Eliminar',
'worktree.action.removeAnyway': 'Descartar y eliminar',
'worktree.action.preserve': 'Conservar',
'worktree.panel.subtitle':
'Worktrees de git aislados creados por workers de agentes en paralelo.',
'worktree.panel.loading': 'Cargando worktrees…',
'worktree.panel.failedToLoad': 'No se pudieron cargar los worktrees',
'worktree.panel.empty':
'No hay worktrees aislados. Los workers en paralelo con aislamiento de worktree aparecerán aquí.',
'worktree.panel.overlapsTitle': 'Cambios superpuestos',
'worktree.panel.overlapHint':
'Estos archivos fueron modificados por más de un worktree: concilia antes de fusionar.',
'intelligence.agentWork.action.stop': 'Detener',
'intelligence.agentWork.action.retry': 'Reintentar',
'intelligence.agentWork.action.continue': 'Continuar',
+29
View File
@@ -428,6 +428,9 @@ const messages: TranslationMap = {
'memory.tab.agentWork': "Travail de l'agent",
'memory.tab.agentWorkDescription':
"Un centre de commande pour chaque exécution d'agent en arrière-plan, regroupée selon ce qui nécessite votre intervention, ce qui est en cours et ce qui est terminé.",
'memory.tab.worktrees': 'Worktrees',
'memory.tab.worktreesDescription':
'Worktrees git isolés des workers dagents parallèles — inspectez les modifications, ouvrez-les ou nettoyez-les.',
'memory.tab.teams': 'Équipes',
'memory.tab.teamsDescription':
"Équipes d'agents coordonnées — qui s'occupe de chaque tâche, ce qui est bloqué et ce que les coéquipiers se disent entre eux.",
@@ -3080,6 +3083,32 @@ const messages: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': 'Élément enfant de workflow',
'intelligence.agentWork.openThread': 'Ouvrir le fil',
'intelligence.agentWork.openWorker': 'Ouvrir le worker',
'worktree.label': 'Worktree',
'worktree.dirty': 'Modifications non validées',
'worktree.clean': 'Propre',
'worktree.changedFile': 'fichier modifié',
'worktree.changedFiles': 'fichiers modifiés',
'worktree.removing': 'Suppression…',
'worktree.removeFailed': 'Échec de la suppression du worktree',
'worktree.diffFailed': 'Échec du chargement du diff',
'worktree.diffLoading': 'Chargement du diff…',
'worktree.diffEmpty': 'Aucune modification par rapport à HEAD.',
'worktree.dirtyConfirm':
'Ce worktree contient des modifications non validées. Le supprimer abandonne ce travail. La branche du worker est conservée.',
'worktree.action.open': 'Ouvrir le dossier',
'worktree.action.diff': 'Diff',
'worktree.action.hideDiff': 'Masquer le diff',
'worktree.action.remove': 'Supprimer',
'worktree.action.removeAnyway': 'Abandonner et supprimer',
'worktree.action.preserve': 'Conserver',
'worktree.panel.subtitle': 'Worktrees git isolés créés par des workers dagents parallèles.',
'worktree.panel.loading': 'Chargement des worktrees…',
'worktree.panel.failedToLoad': 'Échec du chargement des worktrees',
'worktree.panel.empty':
'Aucun worktree isolé. Les workers parallèles avec isolation par worktree apparaîtront ici.',
'worktree.panel.overlapsTitle': 'Modifications qui se chevauchent',
'worktree.panel.overlapHint':
'Ces fichiers ont été modifiés par plusieurs worktrees — réconciliez-les avant de fusionner.',
'intelligence.agentWork.action.stop': 'Arrêter',
'intelligence.agentWork.action.retry': 'Réessayer',
'intelligence.agentWork.action.continue': 'Continuer',
+29
View File
@@ -413,6 +413,9 @@ const messages: TranslationMap = {
'memory.tab.agentWork': 'एजेंट कार्य',
'memory.tab.agentWorkDescription':
'हर पृष्ठभूमि एजेंट रन के लिए एक कमांड सेंटर — इस आधार पर समूहित कि किसे आपकी जानकारी चाहिए, क्या चल रहा है और क्या पूरा हो चुका है।',
'memory.tab.worktrees': 'वर्कट्री',
'memory.tab.worktreesDescription':
'समानांतर एजेंट वर्कर्स से पृथक git वर्कट्री — बदलावों का निरीक्षण करें, उन्हें खोलें या साफ़ करें।',
'memory.tab.teams': 'टीमें',
'memory.tab.teamsDescription':
'समन्वित एजेंट टीमें — हर कार्य का स्वामी कौन है, क्या अवरुद्ध है, और सहयोगी एक-दूसरे से क्या कह रहे हैं।',
@@ -3010,6 +3013,32 @@ const messages: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': 'वर्कफ़्लो चाइल्ड',
'intelligence.agentWork.openThread': 'थ्रेड खोलें',
'intelligence.agentWork.openWorker': 'वर्कर खोलें',
'worktree.label': 'वर्कट्री',
'worktree.dirty': 'बिना कमिट किए बदलाव',
'worktree.clean': 'साफ़',
'worktree.changedFile': 'बदली गई फ़ाइल',
'worktree.changedFiles': 'बदली गई फ़ाइलें',
'worktree.removing': 'हटाया जा रहा है…',
'worktree.removeFailed': 'वर्कट्री हटाने में विफल',
'worktree.diffFailed': 'डिफ़ लोड करने में विफल',
'worktree.diffLoading': 'डिफ़ लोड हो रहा है…',
'worktree.diffEmpty': 'HEAD की तुलना में कोई बदलाव नहीं।',
'worktree.dirtyConfirm':
'इस वर्कट्री में बिना कमिट किए बदलाव हैं। इसे हटाने पर वह काम छूट जाएगा। वर्कर ब्रांच बनी रहती है।',
'worktree.action.open': 'फ़ोल्डर खोलें',
'worktree.action.diff': 'डिफ़',
'worktree.action.hideDiff': 'डिफ़ छिपाएँ',
'worktree.action.remove': 'हटाएँ',
'worktree.action.removeAnyway': 'छोड़ें और हटाएँ',
'worktree.action.preserve': 'सुरक्षित रखें',
'worktree.panel.subtitle': 'समानांतर एजेंट वर्कर्स द्वारा बनाए गए पृथक git वर्कट्री।',
'worktree.panel.loading': 'वर्कट्री लोड हो रही हैं…',
'worktree.panel.failedToLoad': 'वर्कट्री लोड करने में विफल',
'worktree.panel.empty':
'कोई पृथक वर्कट्री नहीं। वर्कट्री पृथक्करण वाले समानांतर वर्कर्स यहाँ दिखाई देंगे।',
'worktree.panel.overlapsTitle': 'ओवरलैपिंग बदलाव',
'worktree.panel.overlapHint':
'ये फ़ाइलें एक से अधिक वर्कट्री द्वारा बदली गई थीं — मर्ज करने से पहले समाधान करें।',
'intelligence.agentWork.action.stop': 'रोकें',
'intelligence.agentWork.action.retry': 'फिर से प्रयास करें',
'intelligence.agentWork.action.continue': 'जारी रखें',
+29
View File
@@ -417,6 +417,9 @@ const messages: TranslationMap = {
'memory.tab.agentWork': 'Kerja agen',
'memory.tab.agentWorkDescription':
'Pusat kendali untuk setiap proses agen latar belakang — dikelompokkan berdasarkan apa yang butuh masukan Anda, apa yang sedang berjalan, dan apa yang sudah selesai.',
'memory.tab.worktrees': 'Worktree',
'memory.tab.worktreesDescription':
'Worktree git terisolasi dari worker agen paralel — periksa perubahan, buka, atau bersihkan.',
'memory.tab.teams': 'Tim',
'memory.tab.teamsDescription':
'Tim agen yang terkoordinasi — siapa yang menangani setiap tugas, apa yang terhambat, dan apa yang dibicarakan rekan satu tim satu sama lain.',
@@ -3014,6 +3017,32 @@ const messages: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': 'Anak alur kerja',
'intelligence.agentWork.openThread': 'Buka utas',
'intelligence.agentWork.openWorker': 'Buka pekerja',
'worktree.label': 'Worktree',
'worktree.dirty': 'Perubahan belum di-commit',
'worktree.clean': 'Bersih',
'worktree.changedFile': 'berkas berubah',
'worktree.changedFiles': 'berkas berubah',
'worktree.removing': 'Menghapus…',
'worktree.removeFailed': 'Gagal menghapus worktree',
'worktree.diffFailed': 'Gagal memuat diff',
'worktree.diffLoading': 'Memuat diff…',
'worktree.diffEmpty': 'Tidak ada perubahan dibandingkan HEAD.',
'worktree.dirtyConfirm':
'Worktree ini memiliki perubahan yang belum di-commit. Menghapusnya akan membuang pekerjaan itu. Branch worker tetap dipertahankan.',
'worktree.action.open': 'Buka folder',
'worktree.action.diff': 'Diff',
'worktree.action.hideDiff': 'Sembunyikan diff',
'worktree.action.remove': 'Hapus',
'worktree.action.removeAnyway': 'Buang & hapus',
'worktree.action.preserve': 'Pertahankan',
'worktree.panel.subtitle': 'Worktree git terisolasi yang dibuat oleh worker agen paralel.',
'worktree.panel.loading': 'Memuat worktree…',
'worktree.panel.failedToLoad': 'Gagal memuat worktree',
'worktree.panel.empty':
'Tidak ada worktree terisolasi. Worker paralel dengan isolasi worktree akan muncul di sini.',
'worktree.panel.overlapsTitle': 'Perubahan yang tumpang tindih',
'worktree.panel.overlapHint':
'Berkas-berkas ini diubah oleh lebih dari satu worktree — selaraskan sebelum menggabungkan.',
'intelligence.agentWork.action.stop': 'Hentikan',
'intelligence.agentWork.action.retry': 'Coba lagi',
'intelligence.agentWork.action.continue': 'Lanjutkan',
+29
View File
@@ -424,6 +424,9 @@ const messages: TranslationMap = {
'memory.tab.agentWork': "Lavoro dell'agente",
'memory.tab.agentWorkDescription':
'Un centro di comando per ogni esecuzione di agente in background, raggruppata in base a ciò che richiede il tuo intervento, ciò che è in corso e ciò che è terminato.',
'memory.tab.worktrees': 'Worktree',
'memory.tab.worktreesDescription':
'Worktree git isolati dai worker degli agenti paralleli: ispeziona le modifiche, aprile o ripuliscile.',
'memory.tab.teams': 'Team',
'memory.tab.teamsDescription':
'Team di agenti coordinati — chi è responsabile di ogni attività, cosa è bloccato e cosa si dicono i compagni di team tra loro.',
@@ -3057,6 +3060,32 @@ const messages: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': 'Figlio del flusso di lavoro',
'intelligence.agentWork.openThread': 'Apri thread',
'intelligence.agentWork.openWorker': 'Apri worker',
'worktree.label': 'Worktree',
'worktree.dirty': 'Modifiche non sottoposte a commit',
'worktree.clean': 'Pulito',
'worktree.changedFile': 'file modificato',
'worktree.changedFiles': 'file modificati',
'worktree.removing': 'Rimozione…',
'worktree.removeFailed': 'Impossibile rimuovere il worktree',
'worktree.diffFailed': 'Impossibile caricare il diff',
'worktree.diffLoading': 'Caricamento del diff…',
'worktree.diffEmpty': 'Nessuna modifica rispetto a HEAD.',
'worktree.dirtyConfirm':
'Questo worktree ha modifiche non sottoposte a commit. Rimuoverlo scarta quel lavoro. Il branch del worker viene mantenuto.',
'worktree.action.open': 'Apri cartella',
'worktree.action.diff': 'Diff',
'worktree.action.hideDiff': 'Nascondi diff',
'worktree.action.remove': 'Rimuovi',
'worktree.action.removeAnyway': 'Scarta e rimuovi',
'worktree.action.preserve': 'Conserva',
'worktree.panel.subtitle': 'Worktree git isolati creati da worker di agenti paralleli.',
'worktree.panel.loading': 'Caricamento dei worktree…',
'worktree.panel.failedToLoad': 'Impossibile caricare i worktree',
'worktree.panel.empty':
'Nessun worktree isolato. I worker paralleli con isolamento tramite worktree appariranno qui.',
'worktree.panel.overlapsTitle': 'Modifiche sovrapposte',
'worktree.panel.overlapHint':
'Questi file sono stati modificati da più di un worktree: riconcilia prima di unire.',
'intelligence.agentWork.action.stop': 'Interrompi',
'intelligence.agentWork.action.retry': 'Riprova',
'intelligence.agentWork.action.continue': 'Continua',
+29
View File
@@ -414,6 +414,9 @@ const messages: TranslationMap = {
'memory.tab.agentWork': '에이전트 작업',
'memory.tab.agentWorkDescription':
'모든 백그라운드 에이전트 실행을 위한 명령 센터 — 입력이 필요한 것, 진행 중인 것, 완료된 것별로 그룹화됩니다.',
'memory.tab.worktrees': '워크트리',
'memory.tab.worktreesDescription':
'병렬 에이전트 워커가 만든 격리된 git 워크트리 — 변경 사항을 확인하거나 열거나 정리하세요.',
'memory.tab.teams': '팀',
'memory.tab.teamsDescription':
'조율된 에이전트 팀 — 각 작업을 누가 담당하는지, 무엇이 차단되어 있는지, 그리고 팀원들이 서로 무슨 이야기를 나누는지.',
@@ -2985,6 +2988,32 @@ const messages: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': '워크플로 하위 항목',
'intelligence.agentWork.openThread': '스레드 열기',
'intelligence.agentWork.openWorker': '워커 열기',
'worktree.label': '워크트리',
'worktree.dirty': '커밋되지 않은 변경 사항',
'worktree.clean': '깨끗함',
'worktree.changedFile': '변경된 파일',
'worktree.changedFiles': '변경된 파일',
'worktree.removing': '제거 중…',
'worktree.removeFailed': '워크트리 제거 실패',
'worktree.diffFailed': 'diff 불러오기 실패',
'worktree.diffLoading': 'diff 불러오는 중…',
'worktree.diffEmpty': 'HEAD 대비 변경 사항이 없습니다.',
'worktree.dirtyConfirm':
'이 워크트리에는 커밋되지 않은 변경 사항이 있습니다. 제거하면 해당 작업이 삭제됩니다. 워커 브랜치는 유지됩니다.',
'worktree.action.open': '폴더 열기',
'worktree.action.diff': 'Diff',
'worktree.action.hideDiff': 'diff 숨기기',
'worktree.action.remove': '제거',
'worktree.action.removeAnyway': '버리고 제거',
'worktree.action.preserve': '보존',
'worktree.panel.subtitle': '병렬 에이전트 워커가 생성한 격리된 git 워크트리입니다.',
'worktree.panel.loading': '워크트리 불러오는 중…',
'worktree.panel.failedToLoad': '워크트리 불러오기 실패',
'worktree.panel.empty':
'격리된 워크트리가 없습니다. 워크트리 격리를 사용하는 병렬 워커가 여기에 표시됩니다.',
'worktree.panel.overlapsTitle': '겹치는 변경 사항',
'worktree.panel.overlapHint':
'이 파일들은 둘 이상의 워크트리에서 변경되었습니다 — 병합하기 전에 조정하세요.',
'intelligence.agentWork.action.stop': '중지',
'intelligence.agentWork.action.retry': '다시 시도',
'intelligence.agentWork.action.continue': '계속',
+30
View File
@@ -420,6 +420,9 @@ const messages: TranslationMap = {
'memory.tab.agentWork': 'Praca agenta',
'memory.tab.agentWorkDescription':
'Centrum dowodzenia dla każdego przebiegu agenta w tle — pogrupowane według tego, co wymaga Twojej reakcji, co działa i co zostało ukończone.',
'memory.tab.worktrees': 'Worktree',
'memory.tab.worktreesDescription':
'Izolowane drzewa robocze git od równoległych procesów agentów — przejrzyj zmiany, otwórz je lub wyczyść.',
'memory.tab.teams': 'Zespoły',
'memory.tab.teamsDescription':
'Skoordynowane zespoły agentów — kto odpowiada za każde zadanie, co jest zablokowane i co członkowie zespołu mówią sobie nawzajem.',
@@ -3047,6 +3050,33 @@ const messages: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': 'Element podrzędny przepływu',
'intelligence.agentWork.openThread': 'Otwórz wątek',
'intelligence.agentWork.openWorker': 'Otwórz proces',
'worktree.label': 'Worktree',
'worktree.dirty': 'Niezatwierdzone zmiany',
'worktree.clean': 'Czyste',
'worktree.changedFile': 'zmieniony plik',
'worktree.changedFiles': 'zmienione pliki',
'worktree.removing': 'Usuwanie…',
'worktree.removeFailed': 'Nie udało się usunąć worktree',
'worktree.diffFailed': 'Nie udało się załadować diffa',
'worktree.diffLoading': 'Ładowanie diffa…',
'worktree.diffEmpty': 'Brak zmian względem HEAD.',
'worktree.dirtyConfirm':
'To worktree ma niezatwierdzone zmiany. Jego usunięcie odrzuci tę pracę. Gałąź workera zostaje zachowana.',
'worktree.action.open': 'Otwórz folder',
'worktree.action.diff': 'Diff',
'worktree.action.hideDiff': 'Ukryj diff',
'worktree.action.remove': 'Usuń',
'worktree.action.removeAnyway': 'Odrzuć i usuń',
'worktree.action.preserve': 'Zachowaj',
'worktree.panel.subtitle':
'Izolowane drzewa robocze git utworzone przez równoległe procesy agentów.',
'worktree.panel.loading': 'Ładowanie worktree…',
'worktree.panel.failedToLoad': 'Nie udało się załadować worktree',
'worktree.panel.empty':
'Brak izolowanych worktree. Pojawią się tutaj równoległe procesy z izolacją worktree.',
'worktree.panel.overlapsTitle': 'Nakładające się zmiany',
'worktree.panel.overlapHint':
'Te pliki zostały zmienione przez więcej niż jedno worktree — uzgodnij je przed scaleniem.',
'intelligence.agentWork.action.stop': 'Zatrzymaj',
'intelligence.agentWork.action.retry': 'Ponów',
'intelligence.agentWork.action.continue': 'Kontynuuj',
+29
View File
@@ -427,6 +427,9 @@ const messages: TranslationMap = {
'memory.tab.agentWork': 'Trabalho do agente',
'memory.tab.agentWorkDescription':
'Um centro de comando para cada execução de agente em segundo plano, agrupada conforme o que precisa da sua intervenção, o que está em andamento e o que foi concluído.',
'memory.tab.worktrees': 'Worktrees',
'memory.tab.worktreesDescription':
'Worktrees git isolados dos workers de agentes paralelos — inspecione as alterações, abra-as ou limpe-as.',
'memory.tab.teams': 'Equipes',
'memory.tab.teamsDescription':
'Equipes de agentes coordenadas — quem é responsável por cada tarefa, o que está bloqueado e o que os colegas de equipe estão dizendo uns aos outros.',
@@ -3061,6 +3064,32 @@ const messages: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': 'Filho de fluxo de trabalho',
'intelligence.agentWork.openThread': 'Abrir tópico',
'intelligence.agentWork.openWorker': 'Abrir trabalhador',
'worktree.label': 'Worktree',
'worktree.dirty': 'Alterações não confirmadas',
'worktree.clean': 'Limpo',
'worktree.changedFile': 'arquivo alterado',
'worktree.changedFiles': 'arquivos alterados',
'worktree.removing': 'Removendo…',
'worktree.removeFailed': 'Falha ao remover o worktree',
'worktree.diffFailed': 'Falha ao carregar o diff',
'worktree.diffLoading': 'Carregando diff…',
'worktree.diffEmpty': 'Nenhuma alteração em relação ao HEAD.',
'worktree.dirtyConfirm':
'Este worktree tem alterações não confirmadas. Removê-lo descarta esse trabalho. O branch do worker é mantido.',
'worktree.action.open': 'Abrir pasta',
'worktree.action.diff': 'Diff',
'worktree.action.hideDiff': 'Ocultar diff',
'worktree.action.remove': 'Remover',
'worktree.action.removeAnyway': 'Descartar e remover',
'worktree.action.preserve': 'Preservar',
'worktree.panel.subtitle': 'Worktrees git isolados criados por workers de agentes paralelos.',
'worktree.panel.loading': 'Carregando worktrees…',
'worktree.panel.failedToLoad': 'Falha ao carregar os worktrees',
'worktree.panel.empty':
'Nenhum worktree isolado. Workers paralelos com isolamento por worktree aparecerão aqui.',
'worktree.panel.overlapsTitle': 'Alterações sobrepostas',
'worktree.panel.overlapHint':
'Estes arquivos foram alterados por mais de um worktree — reconcilie antes de mesclar.',
'intelligence.agentWork.action.stop': 'Parar',
'intelligence.agentWork.action.retry': 'Tentar novamente',
'intelligence.agentWork.action.continue': 'Continuar',
+30
View File
@@ -419,6 +419,9 @@ const messages: TranslationMap = {
'memory.tab.agentWork': 'Работа агента',
'memory.tab.agentWorkDescription':
'Центр управления для каждого фонового запуска агента — сгруппировано по тому, что требует вашего участия, что выполняется и что завершено.',
'memory.tab.worktrees': 'Worktree',
'memory.tab.worktreesDescription':
'Изолированные рабочие деревья git от параллельных воркеров-агентов — просматривайте изменения, открывайте их или удаляйте.',
'memory.tab.teams': 'Команды',
'memory.tab.teamsDescription':
'Скоординированные команды агентов — кто отвечает за каждую задачу, что заблокировано и что участники говорят друг другу.',
@@ -3033,6 +3036,33 @@ const messages: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': 'Дочерний элемент рабочего процесса',
'intelligence.agentWork.openThread': 'Открыть тред',
'intelligence.agentWork.openWorker': 'Открыть воркер',
'worktree.label': 'Worktree',
'worktree.dirty': 'Незакоммиченные изменения',
'worktree.clean': 'Чисто',
'worktree.changedFile': 'изменённый файл',
'worktree.changedFiles': 'изменённых файлов',
'worktree.removing': 'Удаление…',
'worktree.removeFailed': 'Не удалось удалить worktree',
'worktree.diffFailed': 'Не удалось загрузить diff',
'worktree.diffLoading': 'Загрузка diff…',
'worktree.diffEmpty': 'Нет изменений относительно HEAD.',
'worktree.dirtyConfirm':
'В этом worktree есть незакоммиченные изменения. При удалении эта работа будет потеряна. Ветка воркера сохраняется.',
'worktree.action.open': 'Открыть папку',
'worktree.action.diff': 'Diff',
'worktree.action.hideDiff': 'Скрыть diff',
'worktree.action.remove': 'Удалить',
'worktree.action.removeAnyway': 'Отклонить и удалить',
'worktree.action.preserve': 'Сохранить',
'worktree.panel.subtitle':
'Изолированные рабочие деревья git, созданные параллельными воркерами-агентами.',
'worktree.panel.loading': 'Загрузка worktree…',
'worktree.panel.failedToLoad': 'Не удалось загрузить worktree',
'worktree.panel.empty':
'Нет изолированных worktree. Здесь появятся параллельные воркеры с изоляцией worktree.',
'worktree.panel.overlapsTitle': 'Пересекающиеся изменения',
'worktree.panel.overlapHint':
'Эти файлы были изменены более чем одним worktree — согласуйте их перед слиянием.',
'intelligence.agentWork.action.stop': 'Остановить',
'intelligence.agentWork.action.retry': 'Повторить',
'intelligence.agentWork.action.continue': 'Продолжить',
+26
View File
@@ -397,6 +397,9 @@ const messages: TranslationMap = {
'memory.tab.agentWork': '智能体工作',
'memory.tab.agentWorkDescription':
'所有后台智能体运行的指挥中心——按需要你处理的、正在运行的和已完成的进行分组。',
'memory.tab.worktrees': '工作树',
'memory.tab.worktreesDescription':
'来自并行智能体工作进程的隔离 git 工作树——检查更改、打开它们或清理它们。',
'memory.tab.teams': '团队',
'memory.tab.teamsDescription':
'协同的智能体团队——谁负责每项任务、有什么被阻塞,以及队友之间在交流什么。',
@@ -2865,6 +2868,29 @@ const messages: TranslationMap = {
'intelligence.agentWork.kind.workflowChild': '工作流子项',
'intelligence.agentWork.openThread': '打开会话',
'intelligence.agentWork.openWorker': '打开工作线程',
'worktree.label': '工作树',
'worktree.dirty': '未提交的更改',
'worktree.clean': '干净',
'worktree.changedFile': '个更改的文件',
'worktree.changedFiles': '个更改的文件',
'worktree.removing': '正在移除…',
'worktree.removeFailed': '移除工作树失败',
'worktree.diffFailed': '加载差异失败',
'worktree.diffLoading': '正在加载差异…',
'worktree.diffEmpty': '与 HEAD 相比没有更改。',
'worktree.dirtyConfirm': '此工作树有未提交的更改。移除它将丢弃这些工作。工作进程分支会保留。',
'worktree.action.open': '打开文件夹',
'worktree.action.diff': '差异',
'worktree.action.hideDiff': '隐藏差异',
'worktree.action.remove': '移除',
'worktree.action.removeAnyway': '丢弃并移除',
'worktree.action.preserve': '保留',
'worktree.panel.subtitle': '由并行智能体工作进程创建的隔离 git 工作树。',
'worktree.panel.loading': '正在加载工作树…',
'worktree.panel.failedToLoad': '加载工作树失败',
'worktree.panel.empty': '没有隔离的工作树。具有工作树隔离的并行工作进程将显示在此处。',
'worktree.panel.overlapsTitle': '重叠的更改',
'worktree.panel.overlapHint': '这些文件被多个工作树更改过——合并前请先协调。',
'intelligence.agentWork.action.stop': '停止',
'intelligence.agentWork.action.retry': '重试',
'intelligence.agentWork.action.continue': '继续',
+10
View File
@@ -9,6 +9,7 @@ import IntelligenceOrchestrationTab from '../components/intelligence/Intelligenc
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTab';
import IntelligenceTeamsTab from '../components/intelligence/IntelligenceTeamsTab';
import IntelligenceWorktreesTab from '../components/intelligence/IntelligenceWorktreesTab';
import MemorySection from '../components/intelligence/MemorySection';
import ModelCouncilTab from '../components/intelligence/ModelCouncilTab';
import { ToastContainer } from '../components/intelligence/Toast';
@@ -35,6 +36,7 @@ type IntelligenceTab =
| 'subconscious'
| 'tasks'
| 'agent-work'
| 'worktrees'
| 'teams'
| 'agents'
| 'workflows'
@@ -46,6 +48,7 @@ const INTELLIGENCE_TABS: IntelligenceTab[] = [
'subconscious',
'tasks',
'agent-work',
'worktrees',
'teams',
'agents',
'workflows',
@@ -173,6 +176,11 @@ export default function Intelligence({ tabParamKey = 'tab' }: IntelligenceProps
label: t('memory.tab.agentWork'),
description: t('memory.tab.agentWorkDescription'),
},
{
id: 'worktrees',
label: t('memory.tab.worktrees'),
description: t('memory.tab.worktreesDescription'),
},
{ id: 'teams', label: t('memory.tab.teams'), description: t('memory.tab.teamsDescription') },
{ id: 'memory', label: t('memory.tab.memory') },
{ id: 'subconscious', label: t('memory.tab.subconscious') },
@@ -275,6 +283,8 @@ export default function Intelligence({ tabParamKey = 'tab' }: IntelligenceProps
{activeTab === 'agent-work' && <IntelligenceAgentWorkTab />}
{activeTab === 'worktrees' && <IntelligenceWorktreesTab />}
{activeTab === 'teams' && <IntelligenceTeamsTab />}
{activeTab === 'agents' && <IntelligenceAgentsTab />}
@@ -1,5 +1,7 @@
import WorktreeActions from '../../../components/worktree/WorktreeActions';
import { useT } from '../../../lib/i18n/I18nContext';
import type { SubagentActivity, ToolTimelineEntry } from '../../../store/chatRuntimeSlice';
import { basename } from '../../../utils/pathUtils';
import { formatTimelineEntry, formatToolName } from '../../../utils/toolTimelineFormatting';
import { parseWorkerThreadRef } from '../utils/workerThreadRef';
import { agentNameTone, AgentTimelineRail } from './AgentTimelineRail';
@@ -149,6 +151,40 @@ export function SubagentActivityBlock({
<span className="line-clamp-2 break-words italic">{preview}</span>
</div>
) : null}
{subagent.worktreePath ? (
<div
className="mt-1 space-y-1 rounded-md border border-stone-200 bg-stone-50/70 p-1.5 dark:border-neutral-800 dark:bg-neutral-900/50"
data-testid="subagent-worktree">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium text-stone-600 dark:text-neutral-300">
{t('worktree.label')}
</span>
<span
className="truncate font-mono text-[10px] text-stone-500 dark:text-neutral-400"
title={subagent.worktreePath}>
{basename(subagent.worktreePath)}
</span>
{subagent.isDirty ? (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-medium text-amber-700 dark:bg-amber-500/15 dark:text-amber-300">
{t('worktree.dirty')}
</span>
) : (
<span className="rounded-full bg-sage-100 px-1.5 py-0.5 text-[9px] font-medium text-sage-700 dark:bg-sage-500/15 dark:text-sage-300">
{t('worktree.clean')}
</span>
)}
{subagent.changedFiles && subagent.changedFiles.length > 0 ? (
<span className="text-[9px] text-stone-400 dark:text-neutral-500">
{subagent.changedFiles.length}{' '}
{subagent.changedFiles.length === 1
? t('worktree.changedFile')
: t('worktree.changedFiles')}
</span>
) : null}
</div>
<WorktreeActions path={subagent.worktreePath} isDirty={subagent.isDirty} compact />
</div>
) : null}
{onView ? (
<button
type="button"
@@ -145,6 +145,34 @@ describe('SubagentActivityBlock', () => {
await userEvent.click(btn);
expect(onView).toHaveBeenCalledTimes(1);
});
it('renders the inline worktree block + actions when worktreePath is set (#3376)', () => {
renderInStore(
<SubagentActivityBlock
subagent={{
taskId: 't',
agentId: 'coder',
toolCalls: [],
worktreePath: '/r/.claude/worktrees/worker-a',
changedFiles: ['src/lib.rs'],
isDirty: true,
}}
/>
);
const block = screen.getByTestId('subagent-worktree');
expect(block).toBeInTheDocument();
// Compact label shows the basename, not the full path.
expect(block).toHaveTextContent('worker-a');
expect(screen.getByTestId('worktree-actions')).toBeInTheDocument();
expect(screen.getByTestId('worktree-remove')).toBeInTheDocument();
});
it('omits the worktree block for a non-isolated subagent', () => {
renderInStore(
<SubagentActivityBlock subagent={{ taskId: 't', agentId: 'researcher', toolCalls: [] }} />
);
expect(screen.queryByTestId('subagent-worktree')).toBeNull();
});
});
describe('ToolTimelineBlock — agentic task insights surface', () => {
@@ -540,6 +540,12 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
iterations: event.subagent?.iterations ?? entry.subagent.iterations,
elapsedMs: event.subagent?.elapsed_ms ?? entry.subagent.elapsedMs,
outputChars: event.subagent?.output_chars ?? entry.subagent.outputChars,
// Worktree isolation metadata (#3376) — present only for
// workers that ran with `isolation = "worktree"`. Drives the
// inline worktree row's open/diff/remove affordances.
worktreePath: event.subagent?.worktree_path ?? entry.subagent.worktreePath,
changedFiles: event.subagent?.changed_files ?? entry.subagent.changedFiles,
isDirty: event.subagent?.dirty_status ?? entry.subagent.isDirty,
}
: entry.subagent,
});
+67
View File
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { callCoreRpc } from '../coreRpcClient';
import { worktreeApi } from './worktreeApi';
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
const mockRpc = vi.mocked(callCoreRpc);
describe('worktreeApi', () => {
beforeEach(() => {
mockRpc.mockReset();
});
it('list calls worktree_list and returns the view', async () => {
const view = { worktrees: [], overlaps: [] };
mockRpc.mockResolvedValueOnce(view);
const out = await worktreeApi.list();
expect(mockRpc).toHaveBeenCalledWith({ method: 'openhuman.worktree_list', params: {} });
expect(out).toBe(view);
});
it('status forwards the path', async () => {
const status = { path: '/r/.claude/worktrees/a', isDirty: false, changedFiles: [] };
mockRpc.mockResolvedValueOnce(status);
const out = await worktreeApi.status('/r/.claude/worktrees/a');
expect(mockRpc).toHaveBeenCalledWith({
method: 'openhuman.worktree_status',
params: { path: '/r/.claude/worktrees/a' },
});
expect(out).toBe(status);
});
it('status rejects a blank path without calling RPC', async () => {
await expect(worktreeApi.status(' ')).rejects.toThrow('path is required');
expect(mockRpc).not.toHaveBeenCalled();
});
it('diff unwraps the summary string', async () => {
mockRpc.mockResolvedValueOnce({ summary: ' src/a.rs | 2 +-' });
const out = await worktreeApi.diff('/r/.claude/worktrees/a');
expect(mockRpc).toHaveBeenCalledWith({
method: 'openhuman.worktree_diff',
params: { path: '/r/.claude/worktrees/a' },
});
expect(out).toBe(' src/a.rs | 2 +-');
});
it('remove defaults force to false and unwraps removed', async () => {
mockRpc.mockResolvedValueOnce({ removed: true });
const out = await worktreeApi.remove('/r/.claude/worktrees/a');
expect(mockRpc).toHaveBeenCalledWith({
method: 'openhuman.worktree_remove',
params: { path: '/r/.claude/worktrees/a', force: false },
});
expect(out).toBe(true);
});
it('remove forwards force=true', async () => {
mockRpc.mockResolvedValueOnce({ removed: true });
await worktreeApi.remove('/r/.claude/worktrees/a', true);
expect(mockRpc).toHaveBeenCalledWith({
method: 'openhuman.worktree_remove',
params: { path: '/r/.claude/worktrees/a', force: true },
});
});
});
+92
View File
@@ -0,0 +1,92 @@
/**
* Frontend client for the git-worktree manager (#3376) — the isolated worker
* worktrees created by `spawn_parallel_agents` when a worker opts into
* `isolation = "worktree"`.
*
* Wraps four read/cleanup RPCs under the `worktree` namespace:
* - `openhuman.worktree_list` — managed worktrees + cross-worker overlaps.
* - `openhuman.worktree_status` — branch / dirty / changed-files for one path.
* - `openhuman.worktree_diff` — human-readable `--stat` diff for one path.
* - `openhuman.worktree_remove` — remove a worktree (refuses dirty unless force).
*
* The wire payloads are already camelCase (the Rust `WorktreeStatus` serializes
* with `#[serde(rename_all = "camelCase")]`), so this client only types the
* shape — no snake/camel transform.
*/
import debug from 'debug';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('worktreeApi');
/** Snapshot of a single worktree. Mirrors the Rust `WorktreeStatus`. */
export interface WorktreeStatus {
/** Absolute path to the worktree checkout. */
path: string;
/** Checked-out branch (or `(detached HEAD)`), when resolvable. */
branch?: string | null;
/** Whether the worktree has uncommitted changes. */
isDirty: boolean;
/** Paths (relative to the worktree root) that differ from HEAD. */
changedFiles: string[];
}
/** A file changed by more than one worker worktree (pre-merge conflict hint). */
export interface WorktreeOverlap {
/** Relative path touched by multiple worktrees. */
file: string;
/** Branches (or path fallbacks) of the worktrees that touched it. */
branches: string[];
}
/** Response from `openhuman.worktree_list`. */
export interface WorktreeListView {
worktrees: WorktreeStatus[];
overlaps: WorktreeOverlap[];
}
export const worktreeApi = {
/** List managed worker worktrees plus cross-worktree file overlaps. */
list: async (): Promise<WorktreeListView> => {
log('list');
const view = await callCoreRpc<WorktreeListView>({
method: 'openhuman.worktree_list',
params: {},
});
log('list received count=%d overlaps=%d', view.worktrees.length, view.overlaps.length);
return view;
},
/** Fetch the branch / dirty / changed-files snapshot for one worktree. */
status: async (path: string): Promise<WorktreeStatus> => {
if (!path.trim()) throw new Error('worktreeApi.status: path is required');
log('status path=%s', path);
return callCoreRpc<WorktreeStatus>({ method: 'openhuman.worktree_status', params: { path } });
},
/** Fetch a human-readable `git diff HEAD --stat` (plus untracked files). */
diff: async (path: string): Promise<string> => {
if (!path.trim()) throw new Error('worktreeApi.diff: path is required');
log('diff path=%s', path);
const res = await callCoreRpc<{ summary: string }>({
method: 'openhuman.worktree_diff',
params: { path },
});
return res.summary;
},
/**
* Remove a worktree checkout. The core refuses a dirty worktree unless
* `force` is `true`, so a clean worktree removes silently while a dirty one
* rejects (the caller surfaces a confirm prompt and retries with force).
*/
remove: async (path: string, force = false): Promise<boolean> => {
if (!path.trim()) throw new Error('worktreeApi.remove: path is required');
log('remove path=%s force=%s', path, force);
const res = await callCoreRpc<{ removed: boolean }>({
method: 'openhuman.worktree_remove',
params: { path, force },
});
return res.removed;
},
};
+10
View File
@@ -252,6 +252,16 @@ export interface SubagentProgressDetail {
worker_thread_id?: string;
/** Human-readable display name from the agent registry. */
display_name?: string;
/**
* Absolute path to the worker's isolated `git worktree` checkout (on
* `subagent_completed`, when the worker ran with `isolation = "worktree"`).
* Drives the inline worktree row's open/diff/remove actions (#3376).
*/
worktree_path?: string;
/** Files (relative to the worktree root) the worker changed (on `subagent_completed`). */
changed_files?: string[];
/** Whether the worker's worktree had uncommitted changes (on `subagent_completed`). */
dirty_status?: boolean;
}
/** Extended payload for `subagent_spawned`. */
+18
View File
@@ -0,0 +1,18 @@
/**
* Shared path-string helpers for display in the UI.
*
* These operate on plain strings (not the filesystem) so they work for both
* POSIX and Windows-style paths surfaced by the Rust core.
*/
/**
* Last path segment of a path string, for a compact label.
*
* Trims trailing slashes/backslashes, then returns everything after the final
* `/` or `\`. Returns the (trimmed) input unchanged when it has no separator.
*/
export function basename(path: string): string {
const trimmed = path.replace(/[/\\]+$/, '');
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed;
}
+5
View File
@@ -306,6 +306,9 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
// Durable agent-team coordination — teams, members, dependency-aware task claiming, messaging
controllers
.extend(crate::openhuman::agent_orchestration::all_agent_team_registered_controllers());
// Git-worktree isolation manager — list / status / diff / remove worker worktrees (#3376)
controllers
.extend(crate::openhuman::agent_orchestration::all_worktree_registered_controllers());
controllers
}
@@ -440,6 +443,8 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::agent_orchestration::all_workflow_run_controller_schemas());
// Durable agent-team coordination
schemas.extend(crate::openhuman::agent_orchestration::all_agent_team_controller_schemas());
// Git-worktree isolation manager (#3376)
schemas.extend(crate::openhuman::agent_orchestration::all_worktree_controller_schemas());
schemas
}
+16
View File
@@ -261,6 +261,22 @@ pub struct SubagentProgressDetail {
/// consistent agent labels across timeline, sub-mascots, and drawer.
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
/// Absolute path to the worker's isolated `git worktree` checkout
/// (on `subagent_completed`, when the worker ran with
/// `isolation = "worktree"`). Drives the inline worktree row's
/// open/diff/remove actions. `None` for non-isolated workers (#3376).
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree_path: Option<String>,
/// Files (relative to the worktree root) the worker changed, snapshot
/// after the run (on `subagent_completed`). Absent for non-isolated
/// workers and clean worktrees.
#[serde(skip_serializing_if = "Option::is_none")]
pub changed_files: Option<Vec<String>>,
/// Whether the worker's worktree had uncommitted changes after the run
/// (on `subagent_completed`). A dirty worktree must not be auto-removed —
/// the UI requires an explicit user decision. `None` for non-isolated.
#[serde(skip_serializing_if = "Option::is_none")]
pub dirty_status: Option<bool>,
}
#[derive(Debug, Deserialize)]
+10
View File
@@ -523,6 +523,16 @@ pub(super) const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::Beta,
privacy: DERIVED_TO_BACKEND,
},
Capability {
id: "intelligence.worktree_manager",
name: "Agent Worktrees",
domain: "intelligence",
category: CapabilityCategory::Intelligence,
description: "Inspect and clean up the isolated git worktrees that parallel sub-agents check out under <repo>/.claude/worktrees. Each row shows the worktree's branch, dirty state, and changed files, plus a cross-worktree overlap warning when two workers touched the same file. Open, diff, or remove a worktree (a dirty worktree requires an explicit discard confirmation; the worker branch is preserved).",
how_to: "Intelligence > Worktrees",
status: CapabilityStatus::Beta,
privacy: None,
},
Capability {
id: "intelligence.slack_memory_ingest",
name: "Slack Memory Ingestion",
+12
View File
@@ -91,6 +91,18 @@ pub enum AgentProgress {
iterations: u32,
/// Character length of the sub-agent's final assistant text.
output_chars: usize,
/// Absolute path to the worker's isolated `git worktree` checkout,
/// when it ran with `isolation = "worktree"` (#3376). `None` for
/// non-isolated (read-only / shared-workspace) workers.
worktree_path: Option<String>,
/// Files (relative to the worktree root) the worker changed, snapshot
/// from `git status` after the run. Empty for non-isolated workers or
/// a clean worktree.
changed_files: Vec<String>,
/// Whether the worker's worktree had uncommitted changes after the
/// run. A dirty worktree must not be auto-removed — surfaced so the UI
/// can require an explicit user decision. `None` for non-isolated.
dirty_status: Option<bool>,
},
/// A sub-agent failed.
+5
View File
@@ -13,6 +13,7 @@ pub mod tools;
pub mod types;
pub mod workflow_runs;
pub mod worktree;
mod worktree_schemas;
#[cfg(test)]
mod ops_tests;
@@ -31,3 +32,7 @@ pub use workflow_runs::{
all_workflow_run_controller_schemas, all_workflow_run_registered_controllers,
};
pub use worktree::{BaseRef, WorktreeError, WorktreeStatus};
pub use worktree_schemas::{
all_controller_schemas as all_worktree_controller_schemas,
all_registered_controllers as all_worktree_registered_controllers,
};
+3
View File
@@ -539,6 +539,9 @@ impl AgentOrchestrationSession {
elapsed_ms: outcome.elapsed.as_millis() as u64,
iterations: outcome.iterations as u32,
output_chars: outcome.output.chars().count(),
worktree_path: None,
changed_files: Vec::new(),
dirty_status: None,
},
));
}
@@ -304,6 +304,9 @@ impl Tool for ContinueSubagentTool {
elapsed_ms: outcome.elapsed.as_millis() as u64,
iterations: outcome.iterations as u32,
output_chars: outcome.output.chars().count(),
worktree_path: None,
changed_files: Vec::new(),
dirty_status: None,
})
.await;
}
@@ -287,6 +287,9 @@ impl Tool for SpawnAsyncSubagentTool {
elapsed_ms: outcome.elapsed.as_millis() as u64,
iterations: outcome.iterations as u32,
output_chars: outcome.output.chars().count(),
worktree_path: None,
changed_files: Vec::new(),
dirty_status: None,
})
.await;
}
@@ -487,6 +487,9 @@ impl Tool for SpawnParallelAgentsTool {
elapsed_ms,
iterations,
output,
worktree_path,
changed_files,
dirty_status,
..
} => {
tracing::debug!(
@@ -516,6 +519,9 @@ impl Tool for SpawnParallelAgentsTool {
.as_ref()
.map(|s| s.chars().count())
.unwrap_or(0),
worktree_path: worktree_path.clone(),
changed_files: changed_files.clone(),
dirty_status: *dirty_status,
})
.await
{
@@ -525,6 +525,9 @@ impl Tool for SpawnSubagentTool {
elapsed_ms: outcome.elapsed.as_millis() as u64,
iterations: outcome.iterations as u32,
output_chars: outcome.output.chars().count(),
worktree_path: None,
changed_files: Vec::new(),
dirty_status: None,
})
.await;
}
@@ -0,0 +1,556 @@
//! Controller schemas + JSON-RPC dispatchers for the git-worktree manager
//! (#3376). Exposes the read + cleanup surface the app needs to list, inspect,
//! diff, and remove the isolated worker worktrees created by
//! [`super::tools::spawn_parallel_agents`].
//!
//! Namespace `worktree`:
//! - `worktree_list` — list isolated worker worktrees + cross-worker overlaps.
//! - `worktree_status` — branch / dirty / changed-files snapshot for one path.
//! - `worktree_diff` — human-readable `--stat` diff for one worktree.
//! - `worktree_remove` — remove a worktree (refuses dirty unless `force=true`).
//!
//! Handlers delegate to [`super::worktree`]; no business logic lives here. The
//! repository root every operation anchors on is the agent's `action_dir`
//! (the user's project repo), resolved from the loaded [`Config`].
//!
//! [`Config`]: crate::openhuman::config::schema::types::Config
use std::path::{Path, PathBuf};
use serde_json::{json, Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::agent_orchestration::worktree::{self, WorktreeError, WorktreeStatus};
use crate::openhuman::config::rpc as config_rpc;
use crate::rpc::RpcOutcome;
/// Controller schemas exposed by the worktree manager.
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schema_for("worktree_list"),
schema_for("worktree_status"),
schema_for("worktree_diff"),
schema_for("worktree_remove"),
]
}
/// Registered controllers (schema + handler) for the worktree manager.
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schema_for("worktree_list"),
handler: handle_list,
},
RegisteredController {
schema: schema_for("worktree_status"),
handler: handle_status,
},
RegisteredController {
schema: schema_for("worktree_diff"),
handler: handle_diff,
},
RegisteredController {
schema: schema_for("worktree_remove"),
handler: handle_remove,
},
]
}
fn schema_for(function: &str) -> ControllerSchema {
match function {
"worktree_list" => ControllerSchema {
namespace: "worktree",
function: "list",
description: "List the isolated worker git worktrees under \
<repo>/.claude/worktrees, each with branch / dirty / \
changed-files state, plus cross-worker file overlaps.",
inputs: vec![],
outputs: vec![json_output(
"result",
"WorktreeListView: { worktrees: WorktreeStatus[], overlaps: \
{ file, branches }[] }.",
)],
},
"worktree_status" => ControllerSchema {
namespace: "worktree",
function: "status",
description: "Branch / dirty / changed-files snapshot for a single \
worktree by absolute path.",
inputs: vec![required_str("path", "Absolute worktree checkout path.")],
outputs: vec![json_output("result", "WorktreeStatus payload.")],
},
"worktree_diff" => ControllerSchema {
namespace: "worktree",
function: "diff",
description: "Human-readable `git diff HEAD --stat` (plus untracked \
files) for a single worktree. Empty for a clean tree.",
inputs: vec![required_str("path", "Absolute worktree checkout path.")],
outputs: vec![json_output("result", "{ summary: string }.")],
},
"worktree_remove" => ControllerSchema {
namespace: "worktree",
function: "remove",
description: "Remove a worktree checkout. Refuses a dirty worktree \
unless force=true (uncommitted work needs an explicit \
user decision). The worker/<id> branch is left intact.",
inputs: vec![
required_str("path", "Absolute worktree checkout path."),
optional_bool(
"force",
"Remove even when the worktree has uncommitted changes \
(default false).",
),
],
outputs: vec![json_output("result", "{ removed: bool }.")],
},
other => unreachable!("unknown worktree schema: {other}"),
}
}
/// Resolve the project repo root every worktree op anchors on — the agent's
/// `action_dir` from the loaded config.
async fn repo_root(cid: &str) -> Result<PathBuf, String> {
let config = config_rpc::load_config_with_timeout()
.await
.inspect_err(|err| {
log::warn!(target: "worktree_rpc", "[worktree_rpc][{cid}] config_failed err={err}");
})?;
Ok(config.action_dir.clone())
}
/// Resolve and validate the `path` param for status/diff/remove.
///
/// These ops must only ever target an isolated worker checkout under
/// `<repo>/.claude/worktrees`. Requiring an **absolute, managed** path before
/// delegating keeps `worktree_remove` (and the read ops) from acting on the
/// main checkout or any arbitrary directory the caller passes.
fn require_managed_worktree_path(params: &Map<String, Value>) -> Result<PathBuf, String> {
let path = params
.get("path")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.map(PathBuf::from)
.ok_or_else(|| "missing required param: path".to_string())?;
if !path.is_absolute() {
return Err("invalid param `path`: absolute path required".to_string());
}
if !is_managed_worktree(&path) {
return Err("invalid param `path`: not a managed worker worktree".to_string());
}
Ok(path)
}
fn handle_list(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let cid = new_correlation_id();
log::debug!(target: "worktree_rpc", "[worktree_rpc][{cid}] list.entry");
let root = repo_root(&cid).await?;
list_view(&root, &cid)
})
}
fn handle_status(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let cid = new_correlation_id();
log::debug!(target: "worktree_rpc", "[worktree_rpc][{cid}] status.entry");
let root = repo_root(&cid).await?;
let path = require_managed_worktree_path(&params)?;
status_view(&root, &path, &cid)
})
}
fn handle_diff(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let cid = new_correlation_id();
log::debug!(target: "worktree_rpc", "[worktree_rpc][{cid}] diff.entry");
let root = repo_root(&cid).await?;
let path = require_managed_worktree_path(&params)?;
diff_view(&root, &path, &cid)
})
}
fn handle_remove(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let cid = new_correlation_id();
log::debug!(target: "worktree_rpc", "[worktree_rpc][{cid}] remove.entry");
let root = repo_root(&cid).await?;
let path = require_managed_worktree_path(&params)?;
let force = params
.get("force")
.and_then(|v| v.as_bool())
.unwrap_or(false);
remove_view(&root, &path, force, &cid)
})
}
/// Pure list logic anchored on an already-resolved `repo_root` — split out of
/// [`handle_list`] so it's unit-testable against a real temp repo without the
/// global config load.
fn list_view(root: &Path, cid: &str) -> Result<Value, String> {
// A non-git action_dir is normal (the user may not have opened a repo).
// Degrade to an empty list rather than surfacing an error to the panel.
let all = match worktree::list(root) {
Ok(list) => list,
Err(WorktreeError::NotAGitRepo(p)) => {
log::debug!(target: "worktree_rpc", "[worktree_rpc][{cid}] list.not_a_git_repo path={}", p.display());
return to_json(json!({ "worktrees": [], "overlaps": [] }));
}
Err(err) => {
let s = err.to_string();
log::warn!(target: "worktree_rpc", "[worktree_rpc][{cid}] list.error err={s}");
return Err(s);
}
};
// Only the isolated worker worktrees are management targets — never the
// main checkout. Filter to those nested under `.claude/worktrees`.
let worktrees: Vec<WorktreeStatus> = all
.into_iter()
.filter(|w| is_managed_worktree(&w.path))
.collect();
let overlaps = overlaps_json(&worktrees);
log::debug!(
target: "worktree_rpc",
"[worktree_rpc][{cid}] list.success count={} overlaps={}",
worktrees.len(),
overlaps.len()
);
to_json(json!({ "worktrees": worktrees, "overlaps": overlaps }))
}
/// Pure status logic anchored on a resolved `repo_root` — see [`list_view`].
fn status_view(root: &Path, path: &Path, cid: &str) -> Result<Value, String> {
log::debug!(target: "worktree_rpc", "[worktree_rpc][{cid}] status.path={}", path.display());
let status = worktree::status(root, path).map_err(|e| {
let s = e.to_string();
log::warn!(target: "worktree_rpc", "[worktree_rpc][{cid}] status.error err={s}");
s
})?;
to_json(status)
}
/// Pure diff logic anchored on a resolved `repo_root` — see [`list_view`].
fn diff_view(root: &Path, path: &Path, cid: &str) -> Result<Value, String> {
log::debug!(target: "worktree_rpc", "[worktree_rpc][{cid}] diff.path={}", path.display());
let summary = worktree::diff_summary(root, path).map_err(|e| {
let s = e.to_string();
log::warn!(target: "worktree_rpc", "[worktree_rpc][{cid}] diff.error err={s}");
s
})?;
to_json(json!({ "summary": summary }))
}
/// Pure remove logic anchored on a resolved `repo_root` — see [`list_view`].
fn remove_view(root: &Path, path: &Path, force: bool, cid: &str) -> Result<Value, String> {
log::debug!(target: "worktree_rpc", "[worktree_rpc][{cid}] remove.path={} force={force}", path.display());
worktree::remove(root, path, force).map_err(|e| {
let s = e.to_string();
log::warn!(target: "worktree_rpc", "[worktree_rpc][{cid}] remove.error err={s}");
s
})?;
log::debug!(target: "worktree_rpc", "[worktree_rpc][{cid}] remove.success path={}", path.display());
to_json(json!({ "removed": true }))
}
/// `true` when `path` is an isolated worker worktree (nested under the
/// `.claude/worktrees` convention dir), i.e. a manageable cleanup target.
fn is_managed_worktree(path: &Path) -> bool {
let needle = std::path::Path::new(worktree::WORKTREE_SUBDIR);
let mut comps = needle.components();
let (Some(a), Some(b)) = (comps.next(), comps.next()) else {
return false;
};
// Match the consecutive ".claude" / "worktrees" segments anywhere in path.
let segs: Vec<_> = path.components().collect();
segs.windows(2).any(|w| w[0] == a && w[1] == b)
}
/// Compute cross-worktree file overlaps (a changed file touched by more than
/// one worktree), keyed for display by each worktree's branch (path fallback).
fn overlaps_json(worktrees: &[WorktreeStatus]) -> Vec<Value> {
let per_worker: Vec<(String, Vec<PathBuf>)> = worktrees
.iter()
.filter(|w| !w.changed_files.is_empty())
.map(|w| {
let label = w
.branch
.clone()
.unwrap_or_else(|| w.path.to_string_lossy().to_string());
(label, w.changed_files.clone())
})
.collect();
worktree::detect_overlaps(&per_worker)
.into_iter()
.map(|(file, branches)| json!({ "file": file.to_string_lossy(), "branches": branches }))
.collect()
}
fn to_json<T: serde::Serialize>(value: T) -> Result<Value, String> {
RpcOutcome::new(value, vec![]).into_cli_compatible_json()
}
fn new_correlation_id() -> String {
uuid::Uuid::new_v4().simple().to_string()[..8].to_string()
}
fn required_str(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::String,
comment,
required: true,
}
}
fn optional_bool(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
comment,
required: false,
}
}
fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Json,
comment,
required: true,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
/// `true` when `git` is invokable on this host. Tests that need real
/// `git worktree` plumbing skip (pass trivially) when it's absent, so a
/// git-less CI image doesn't hard-fail — same convention as
/// `worktree_tests.rs`.
fn git_available() -> bool {
Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.expect("git invocation");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// Temp git repo with one commit. Returns the guard (kept alive by the
/// caller) and the repo root.
fn init_repo() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
git(&root, &["init", "-b", "main"]);
git(&root, &["config", "user.email", "test@example.com"]);
git(&root, &["config", "user.name", "Test User"]);
std::fs::write(root.join("README.md"), "hello\n").unwrap();
git(&root, &["add", "README.md"]);
git(&root, &["commit", "-m", "initial"]);
(tmp, root)
}
#[test]
fn list_view_degrades_to_empty_for_non_git_root() {
if !git_available() {
return;
}
let tmp = tempfile::tempdir().unwrap();
// A plain (non-git) directory must yield an empty list, not an error —
// the panel shows "no worktrees" rather than surfacing a failure.
let v = list_view(tmp.path(), "cid").expect("non-git root degrades cleanly");
assert_eq!(v["worktrees"], json!([]));
assert_eq!(v["overlaps"], json!([]));
}
#[test]
fn list_view_surfaces_managed_worktree() {
if !git_available() {
return;
}
let (_tmp, root) = init_repo();
let st = worktree::create(&root, "run-1", worktree::BaseRef::Head).expect("create");
assert!(
is_managed_worktree(&st.path),
"created under .claude/worktrees"
);
let v = list_view(&root, "cid").expect("list ok");
let worktrees = v["worktrees"].as_array().expect("array");
assert_eq!(worktrees.len(), 1, "the one managed worktree is listed");
// A fresh worktree is clean → no overlaps.
assert_eq!(v["overlaps"], json!([]));
}
/// Drives the full `handle_list` async handler (the public RPC entry point)
/// through `repo_root` → config load, anchored on a non-git
/// `OPENHUMAN_ACTION_DIR`. Confirms the panel-facing path degrades to an
/// empty list rather than erroring. Holds `TEST_ENV_LOCK` because the env
/// override is process-global.
#[tokio::test]
async fn handle_list_degrades_for_non_git_action_dir() {
let _env = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let action_dir = tmp.path().join("actions");
std::fs::create_dir_all(&action_dir).unwrap();
// SAFETY: env writes are serialized by TEST_ENV_LOCK above.
unsafe {
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
std::env::set_var("OPENHUMAN_ACTION_DIR", &action_dir);
}
let out = handle_list(Map::new())
.await
.expect("list handler degrades cleanly for a non-git action_dir");
// `into_cli_compatible_json` returns the bare value when there are no
// logs; the list payload is therefore at the top level.
let payload = out.get("result").unwrap_or(&out);
assert_eq!(payload["worktrees"], json!([]));
assert_eq!(payload["overlaps"], json!([]));
unsafe {
std::env::remove_var("OPENHUMAN_ACTION_DIR");
std::env::remove_var("OPENHUMAN_WORKSPACE");
}
}
#[test]
fn status_and_diff_views_round_trip_a_worktree() {
if !git_available() {
return;
}
let (_tmp, root) = init_repo();
let st = worktree::create(&root, "run-2", worktree::BaseRef::Head).expect("create");
// `WorktreeStatus` serializes `rename_all = "camelCase"` → `isDirty`.
let status = status_view(&root, &st.path, "cid").expect("status ok");
assert_eq!(status["isDirty"], json!(false), "fresh worktree is clean");
// A clean worktree diffs to an empty summary.
let diff = diff_view(&root, &st.path, "cid").expect("diff ok");
assert_eq!(diff["summary"], json!(""));
}
#[test]
fn remove_view_clears_a_clean_worktree() {
if !git_available() {
return;
}
let (_tmp, root) = init_repo();
let st = worktree::create(&root, "run-3", worktree::BaseRef::Head).expect("create");
assert!(st.path.exists());
let removed = remove_view(&root, &st.path, false, "cid").expect("remove ok");
assert_eq!(removed["removed"], json!(true));
assert!(!st.path.exists(), "worktree dir gone after remove");
}
#[test]
fn status_view_errors_on_unknown_path() {
if !git_available() {
return;
}
let (_tmp, root) = init_repo();
let bogus = root.join(".claude/worktrees/never-created");
assert!(status_view(&root, &bogus, "cid").is_err());
}
#[test]
fn correlation_id_is_eight_hex_chars() {
let id = new_correlation_id();
assert_eq!(id.len(), 8);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn registered_controllers_match_schemas() {
let schemas = all_controller_schemas();
let registered = all_registered_controllers();
assert_eq!(schemas.len(), registered.len());
assert_eq!(schemas.len(), 4);
assert!(schemas.iter().all(|s| s.namespace == "worktree"));
assert_eq!(schema_for("worktree_list").function, "list");
assert_eq!(schema_for("worktree_status").function, "status");
assert_eq!(schema_for("worktree_diff").function, "diff");
assert_eq!(schema_for("worktree_remove").function, "remove");
}
#[test]
fn managed_worktree_filter() {
assert!(is_managed_worktree(Path::new(
"/home/u/proj/.claude/worktrees/worker-abc"
)));
assert!(!is_managed_worktree(Path::new("/home/u/proj")));
assert!(!is_managed_worktree(Path::new("/home/u/proj/.claude")));
}
#[test]
fn require_managed_worktree_path_enforces_absolute_and_managed() {
let mut p = Map::new();
// Missing / blank path is rejected.
assert!(require_managed_worktree_path(&p).is_err());
p.insert("path".into(), Value::String(" ".into()));
assert!(require_managed_worktree_path(&p).is_err());
// A relative path is rejected even when it looks managed.
p.insert(
"path".into(),
Value::String(".claude/worktrees/worker-x".into()),
);
assert!(require_managed_worktree_path(&p).is_err());
// An absolute but unmanaged path (e.g. the main checkout) is rejected —
// worktree_remove must never target it.
p.insert("path".into(), Value::String("/home/u/proj".into()));
assert!(require_managed_worktree_path(&p).is_err());
// An absolute, managed worker checkout is accepted.
let ok = "/home/u/proj/.claude/worktrees/worker-x";
p.insert("path".into(), Value::String(ok.into()));
assert_eq!(
require_managed_worktree_path(&p).unwrap(),
PathBuf::from(ok)
);
}
#[test]
fn overlaps_detected_across_branches() {
let worktrees = vec![
WorktreeStatus {
path: PathBuf::from("/r/.claude/worktrees/a"),
branch: Some("worker/a".into()),
is_dirty: true,
changed_files: vec![PathBuf::from("src/lib.rs"), PathBuf::from("a.rs")],
},
WorktreeStatus {
path: PathBuf::from("/r/.claude/worktrees/b"),
branch: Some("worker/b".into()),
is_dirty: true,
changed_files: vec![PathBuf::from("src/lib.rs")],
},
];
let overlaps = overlaps_json(&worktrees);
assert_eq!(overlaps.len(), 1);
assert_eq!(overlaps[0]["file"], json!("src/lib.rs"));
assert_eq!(overlaps[0]["branches"], json!(["worker/a", "worker/b"]));
}
}
@@ -35,6 +35,29 @@ pub(super) fn ledger_upsert_telemetry(
}
}
/// Build the worktree-isolation slice of a `subagent_completed`
/// [`SubagentProgressDetail`] (#3376). An empty `changed_files` collapses to
/// `None` so the renderer omits an empty "changed files" list rather than
/// showing "0 files"; a non-empty list is forwarded verbatim. `worktree_path`
/// / `dirty_status` pass through (`None` for non-isolated workers). Split out
/// so the empty/non-empty branch is unit-testable without a live DB + channel.
fn subagent_worktree_detail(
worktree_path: Option<String>,
changed_files: Vec<String>,
dirty_status: Option<bool>,
) -> SubagentProgressDetail {
SubagentProgressDetail {
worktree_path,
changed_files: if changed_files.is_empty() {
None
} else {
Some(changed_files)
},
dirty_status,
..Default::default()
}
}
/// Spawn a background task that reads [`AgentProgress`] events from the
/// agent turn loop and translates them into [`WebChannelEvent`]s tagged
/// with the correct client/thread/request IDs. The task runs until the
@@ -384,6 +407,9 @@ pub(crate) fn spawn_progress_bridge(
elapsed_ms,
iterations,
output_chars,
worktree_path,
changed_files,
dirty_status,
} => {
let completed_at = chrono::Utc::now();
ledger_upsert_agent_run(
@@ -428,7 +454,10 @@ pub(crate) fn spawn_progress_bridge(
"agentId": agent_id,
"elapsedMs": elapsed_ms,
"iterations": iterations,
"outputChars": output_chars
"outputChars": output_chars,
"worktreePath": worktree_path,
"changedFiles": changed_files,
"dirtyStatus": dirty_status
}),
},
);
@@ -448,7 +477,10 @@ pub(crate) fn spawn_progress_bridge(
elapsed_ms: Some(elapsed_ms),
iterations: Some(iterations),
output_chars: Some(output_chars as u64),
..Default::default()
// Worktree isolation metadata (#3376) — drives the
// inline subagent worktree row's open/diff/remove
// actions. All `None`/absent for non-isolated workers.
..subagent_worktree_detail(worktree_path, changed_files, dirty_status)
}),
..Default::default()
});
@@ -949,3 +981,38 @@ pub(crate) fn spawn_progress_bridge(
);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn worktree_detail_collapses_empty_changed_files_to_none() {
// Non-isolated / clean worker: empty list → `None` so the renderer
// omits the "changed files" section instead of showing an empty one.
let d = subagent_worktree_detail(None, vec![], None);
assert_eq!(d.worktree_path, None);
assert_eq!(d.changed_files, None);
assert_eq!(d.dirty_status, None);
}
#[test]
fn worktree_detail_forwards_isolated_worker_fields() {
// Isolated worker with uncommitted changes: fields pass through and a
// non-empty list is wrapped in `Some`.
let d = subagent_worktree_detail(
Some("/repo/.claude/worktrees/run-1".to_string()),
vec!["src/lib.rs".to_string(), "README.md".to_string()],
Some(true),
);
assert_eq!(
d.worktree_path.as_deref(),
Some("/repo/.claude/worktrees/run-1")
);
assert_eq!(
d.changed_files,
Some(vec!["src/lib.rs".to_string(), "README.md".to_string()])
);
assert_eq!(d.dirty_status, Some(true));
}
}
@@ -252,6 +252,9 @@ fn subagent_lifecycle_records_and_clears_active() {
elapsed_ms: 1234,
iterations: 2,
output_chars: 80,
worktree_path: None,
changed_files: Vec::new(),
dirty_status: None,
});
let s = m.snapshot();
assert_eq!(s.tool_timeline[0].status, ToolTimelineStatus::Success);
@@ -566,6 +566,9 @@ async fn thread_ops_welcome_migration_and_turn_state_cover_error_and_cleanup_pat
elapsed_ms: 50,
iterations: 2,
output_chars: 100,
worktree_path: None,
changed_files: vec![],
dirty_status: None,
}));
mirror.finish();