feat: add integrate commits to parental branch section and wired UI
Introduce IntegrateCommitsSection to plan and apply commits from source to target branch Wire the integration panel into GitView so users can run integrate flow from the current worktree
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
// (dropdown menu used inside IntegrateCommitsSection)
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { IntegrateCommitsSection } from './git/IntegrateCommitsSection';
|
||||
|
||||
import { GitHeader } from './git/GitHeader';
|
||||
import { GitEmptyState } from './git/GitEmptyState';
|
||||
@@ -45,6 +47,7 @@ import { PullRequestSection } from './git/PullRequestSection';
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||
|
||||
|
||||
type GitViewSnapshot = {
|
||||
directory?: string;
|
||||
selectedPaths: string[];
|
||||
@@ -190,6 +193,7 @@ export const GitView: React.FC = () => {
|
||||
? worktreeMap.get(currentSessionId) ?? undefined
|
||||
: undefined;
|
||||
|
||||
|
||||
const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } =
|
||||
useGitIdentitiesStore();
|
||||
|
||||
@@ -260,6 +264,20 @@ export const GitView: React.FC = () => {
|
||||
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>(
|
||||
initialSnapshot?.generatedHighlights ?? []
|
||||
);
|
||||
|
||||
const repoRootForIntegrate = worktreeMetadata?.projectDirectory || null;
|
||||
const sourceBranchForIntegrate = status?.current || null;
|
||||
const defaultTargetBranch = React.useMemo(() => {
|
||||
const fromMeta = worktreeMetadata?.createdFromBranch;
|
||||
if (typeof fromMeta === 'string' && fromMeta.trim().length > 0) {
|
||||
return fromMeta.trim();
|
||||
}
|
||||
const fromProject = activeProject?.worktreeDefaults?.baseBranch;
|
||||
if (typeof fromProject === 'string' && fromProject.trim().length > 0) {
|
||||
return fromProject.trim();
|
||||
}
|
||||
return 'main';
|
||||
}, [worktreeMetadata?.createdFromBranch, activeProject?.worktreeDefaults?.baseBranch]);
|
||||
const clearGeneratedHighlights = React.useCallback(() => {
|
||||
setGeneratedHighlights([]);
|
||||
}, []);
|
||||
@@ -1030,7 +1048,23 @@ export const GitView: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentDirectory && status?.current ? (
|
||||
{worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate ? (
|
||||
<IntegrateCommitsSection
|
||||
repoRoot={repoRootForIntegrate}
|
||||
sourceBranch={sourceBranchForIntegrate}
|
||||
worktreeMetadata={worktreeMetadata}
|
||||
localBranches={localBranches}
|
||||
defaultTargetBranch={defaultTargetBranch}
|
||||
onRefresh={() => {
|
||||
if (!currentDirectory) return;
|
||||
fetchStatus(currentDirectory, git);
|
||||
fetchBranches(currentDirectory, git);
|
||||
fetchLog(currentDirectory, git, logMaxCountLocal);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{currentDirectory && status?.current && status?.tracking ? (
|
||||
<PullRequestSection
|
||||
directory={currentDirectory}
|
||||
branch={status.current}
|
||||
|
||||
@@ -98,7 +98,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="@container/commit-actions flex items-center gap-2 min-w-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -110,13 +110,15 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
isBusy
|
||||
}
|
||||
type="button"
|
||||
aria-label="Generate"
|
||||
className="commit-actions__btn"
|
||||
>
|
||||
{isGeneratingMessage ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiAiGenerate2 className="size-4 text-primary" />
|
||||
)}
|
||||
Generate
|
||||
<span className="commit-actions__label">Generate</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex-1" />
|
||||
@@ -125,17 +127,18 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
variant="outline"
|
||||
onClick={onCommit}
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="whitespace-nowrap"
|
||||
className="commit-actions__btn whitespace-nowrap"
|
||||
aria-label="Commit"
|
||||
>
|
||||
{commitAction === 'commit' ? (
|
||||
<>
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
Committing...
|
||||
<span className="commit-actions__label">Committing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiGitCommitLine className="size-4" />
|
||||
Commit
|
||||
<span className="commit-actions__label">Commit</span>
|
||||
</>
|
||||
)}
|
||||
</ButtonLarge>
|
||||
@@ -167,16 +170,18 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
variant="default"
|
||||
onClick={onCommitAndPush}
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="commit-actions__btn"
|
||||
aria-label="Commit & Push"
|
||||
>
|
||||
{commitAction === 'commitAndPush' ? (
|
||||
<>
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
Pushing...
|
||||
<span className="commit-actions__label commit-actions__label--long">Pushing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiArrowUpLine className="size-4" />
|
||||
Commit & Push
|
||||
<span className="commit-actions__label commit-actions__label--long">Commit & Push</span>
|
||||
</>
|
||||
)}
|
||||
</ButtonLarge>
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import * as React from 'react';
|
||||
import { RiArrowDownSLine, RiLoader4Line, RiSplitCellsHorizontal } from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useMessageStore } from '@/stores/messageStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { execCommand } from '@/lib/execCommands';
|
||||
import {
|
||||
abortIntegrate,
|
||||
computeIntegratePlan,
|
||||
continueIntegrate,
|
||||
integrateWorktreeCommits,
|
||||
getIntegrateConflictDetails,
|
||||
isCherryPickInProgress,
|
||||
type IntegrateConflictDetails,
|
||||
type IntegrateInProgress,
|
||||
type IntegratePlan,
|
||||
} from '@/lib/git/integrateWorktreeCommits';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
type IntegrateUiState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'ready'; plan: IntegratePlan }
|
||||
| { kind: 'running'; plan: IntegratePlan }
|
||||
| { kind: 'conflict'; state: IntegrateInProgress; details: IntegrateConflictDetails };
|
||||
|
||||
export const IntegrateCommitsSection: React.FC<{
|
||||
repoRoot: string;
|
||||
sourceBranch: string;
|
||||
worktreeMetadata: WorktreeMetadata;
|
||||
localBranches: string[];
|
||||
defaultTargetBranch: string;
|
||||
onRefresh?: () => void;
|
||||
}> = ({
|
||||
repoRoot,
|
||||
sourceBranch,
|
||||
worktreeMetadata,
|
||||
localBranches,
|
||||
defaultTargetBranch,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
|
||||
const [targetBranch, setTargetBranch] = React.useState<string>(defaultTargetBranch);
|
||||
React.useEffect(() => {
|
||||
setTargetBranch(defaultTargetBranch);
|
||||
}, [defaultTargetBranch]);
|
||||
|
||||
const isEligible = Boolean(
|
||||
repoRoot && sourceBranch && targetBranch && targetBranch !== 'HEAD' && sourceBranch !== targetBranch
|
||||
);
|
||||
|
||||
const [ui, setUi] = React.useState<IntegrateUiState>({ kind: 'idle' });
|
||||
const [showAllCommits, setShowAllCommits] = React.useState(false);
|
||||
const [commitSummaries, setCommitSummaries] = React.useState<Array<{ sha: string; short: string; subject: string }>>([]);
|
||||
|
||||
const conflictStorageKey = React.useMemo(() => {
|
||||
if (!currentSessionId) return null;
|
||||
return `openchamber.integrate.conflict:${currentSessionId}`;
|
||||
}, [currentSessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!conflictStorageKey || typeof window === 'undefined') return;
|
||||
const raw = window.localStorage.getItem(conflictStorageKey);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as IntegrateInProgress;
|
||||
if (!parsed?.tempWorktreePath || parsed.repoRoot !== repoRoot) {
|
||||
window.localStorage.removeItem(conflictStorageKey);
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const ok = await isCherryPickInProgress(parsed.tempWorktreePath).catch(() => false);
|
||||
if (!ok) {
|
||||
window.localStorage.removeItem(conflictStorageKey);
|
||||
return;
|
||||
}
|
||||
const details = await getIntegrateConflictDetails(parsed.tempWorktreePath).catch(() => null);
|
||||
if (!details) {
|
||||
return;
|
||||
}
|
||||
setUi({ kind: 'conflict', state: parsed, details });
|
||||
})();
|
||||
} catch {
|
||||
window.localStorage.removeItem(conflictStorageKey);
|
||||
}
|
||||
}, [conflictStorageKey, repoRoot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isEligible) {
|
||||
setUi({ kind: 'idle' });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setUi({ kind: 'loading' });
|
||||
void (async () => {
|
||||
try {
|
||||
const plan = await computeIntegratePlan({ repoRoot, sourceBranch, targetBranch });
|
||||
if (cancelled) return;
|
||||
setUi({ kind: 'ready', plan });
|
||||
|
||||
// Preload commit subjects for preview.
|
||||
if (plan.commits.length > 0) {
|
||||
const max = 50;
|
||||
// Show newest -> oldest.
|
||||
const subset = plan.commits.slice(-max).reverse();
|
||||
const quoted = subset.map((s) => JSON.stringify(s)).join(' ');
|
||||
const result = await execCommand(
|
||||
`git show -s --format=%H%x09%h%x09%s ${quoted}`,
|
||||
repoRoot
|
||||
);
|
||||
const lines = (result.stdout || '').split(/\r?\n/).filter(Boolean);
|
||||
const parsed: Array<{ sha: string; short: string; subject: string }> = [];
|
||||
for (const line of lines) {
|
||||
const [sha, short, subject] = line.split('\t');
|
||||
if (!sha || !short) continue;
|
||||
parsed.push({ sha, short, subject: subject || '' });
|
||||
}
|
||||
if (!cancelled) {
|
||||
setCommitSummaries(parsed);
|
||||
setShowAllCommits(false);
|
||||
}
|
||||
} else {
|
||||
if (!cancelled) {
|
||||
setCommitSummaries([]);
|
||||
setShowAllCommits(false);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setUi({ kind: 'idle' });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isEligible, repoRoot, sourceBranch, targetBranch]);
|
||||
|
||||
const persistTarget = React.useCallback(
|
||||
(branch: string) => {
|
||||
if (!currentSessionId) return;
|
||||
useSessionStore.getState().setWorktreeMetadata(currentSessionId, {
|
||||
...worktreeMetadata,
|
||||
createdFromBranch: branch,
|
||||
});
|
||||
},
|
||||
[currentSessionId, worktreeMetadata]
|
||||
);
|
||||
|
||||
const handleResolveWithAi = React.useCallback(async (payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }) => {
|
||||
setActiveMainTab('chat');
|
||||
if (!currentSessionId) {
|
||||
toast.error('No active session', { description: 'Open a chat session first.' });
|
||||
return;
|
||||
}
|
||||
const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState();
|
||||
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
|
||||
const providerID = currentProviderId || lastUsedProvider?.providerID;
|
||||
const modelID = currentModelId || lastUsedProvider?.modelID;
|
||||
if (!providerID || !modelID) {
|
||||
toast.error('No model selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleText = `Resolve cherry-pick conflicts and keep intent of commit ${payload.state.currentCommit} onto branch ${payload.state.targetBranch}. After edits, report if I can continue process.`;
|
||||
const instructionsText = `Worktree commit integration is in progress.
|
||||
- Repo root: ${payload.state.repoRoot}
|
||||
- Temp target worktree: ${payload.state.tempWorktreePath}
|
||||
- Source branch: ${payload.state.sourceBranch}
|
||||
- Target branch: ${payload.state.targetBranch}
|
||||
|
||||
Goal:
|
||||
- Resolve conflicts inside the temp target worktree directory.
|
||||
- Do NOT change intent of the commit being applied.
|
||||
- After edits, say whether I can click "Continue".
|
||||
`;
|
||||
const payloadText = `Cherry-pick conflict context (JSON)\n${JSON.stringify({
|
||||
repoRoot: payload.state.repoRoot,
|
||||
tempWorktreePath: payload.state.tempWorktreePath,
|
||||
sourceBranch: payload.state.sourceBranch,
|
||||
targetBranch: payload.state.targetBranch,
|
||||
currentCommit: payload.state.currentCommit,
|
||||
remainingCommits: payload.state.remainingCommits,
|
||||
statusPorcelain: payload.details.statusPorcelain,
|
||||
unmergedFiles: payload.details.unmergedFiles,
|
||||
currentPatchMeta: payload.details.currentPatchMeta,
|
||||
currentPatch: payload.details.currentPatch,
|
||||
diff: payload.details.diff,
|
||||
}, null, 2)}`;
|
||||
|
||||
void useMessageStore.getState().sendMessage(
|
||||
visibleText,
|
||||
providerID,
|
||||
modelID,
|
||||
currentAgentName ?? undefined,
|
||||
currentSessionId,
|
||||
undefined,
|
||||
null,
|
||||
[
|
||||
{ text: instructionsText, synthetic: true },
|
||||
{ text: payloadText, synthetic: true },
|
||||
],
|
||||
currentVariant
|
||||
).catch((e) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error('Failed to send message', { description: message });
|
||||
});
|
||||
}, [currentSessionId, setActiveMainTab]);
|
||||
|
||||
const handleMove = React.useCallback(async () => {
|
||||
if (ui.kind !== 'ready') return;
|
||||
if (ui.plan.commits.length === 0) {
|
||||
toast.message('No commits to move');
|
||||
return;
|
||||
}
|
||||
setUi({ kind: 'running', plan: ui.plan });
|
||||
try {
|
||||
const result = await integrateWorktreeCommits(ui.plan);
|
||||
if (result.kind === 'success') {
|
||||
toast.success('Commits moved', {
|
||||
description: `${result.moved} commit${result.moved === 1 ? '' : 's'} into ${ui.plan.targetBranch}`,
|
||||
});
|
||||
const next = await computeIntegratePlan(ui.plan);
|
||||
setUi({ kind: 'ready', plan: next });
|
||||
onRefresh?.();
|
||||
return;
|
||||
}
|
||||
if (result.kind === 'conflict') {
|
||||
toast.error('Cherry-pick conflict', { description: 'Resolve conflicts, then Continue.' });
|
||||
setUi({ kind: 'conflict', state: result.state, details: result.details });
|
||||
if (conflictStorageKey && typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(conflictStorageKey, JSON.stringify(result.state));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error('Failed to move commits', { description: message });
|
||||
const next = await computeIntegratePlan({ repoRoot, sourceBranch, targetBranch }).catch(() => null);
|
||||
if (next) setUi({ kind: 'ready', plan: next });
|
||||
else setUi({ kind: 'idle' });
|
||||
}
|
||||
}, [ui, onRefresh, repoRoot, sourceBranch, targetBranch, conflictStorageKey]);
|
||||
|
||||
const handleAbort = React.useCallback(async () => {
|
||||
if (ui.kind !== 'conflict') return;
|
||||
try {
|
||||
await abortIntegrate(ui.state);
|
||||
toast.message('Cherry-pick aborted');
|
||||
if (conflictStorageKey && typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(conflictStorageKey);
|
||||
}
|
||||
} finally {
|
||||
const next = await computeIntegratePlan({ repoRoot, sourceBranch, targetBranch }).catch(() => null);
|
||||
if (next) setUi({ kind: 'ready', plan: next });
|
||||
else setUi({ kind: 'idle' });
|
||||
}
|
||||
}, [ui, repoRoot, sourceBranch, targetBranch, conflictStorageKey]);
|
||||
|
||||
const handleContinue = React.useCallback(async () => {
|
||||
if (ui.kind !== 'conflict') return;
|
||||
try {
|
||||
const result = await continueIntegrate(ui.state);
|
||||
if (result.kind === 'success') {
|
||||
toast.success('Cherry-pick finished');
|
||||
const next = await computeIntegratePlan({ repoRoot, sourceBranch, targetBranch }).catch(() => null);
|
||||
if (next) setUi({ kind: 'ready', plan: next });
|
||||
else setUi({ kind: 'idle' });
|
||||
if (conflictStorageKey && typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(conflictStorageKey);
|
||||
}
|
||||
onRefresh?.();
|
||||
return;
|
||||
}
|
||||
if (result.kind === 'conflict') {
|
||||
setUi({ kind: 'conflict', state: result.state, details: result.details });
|
||||
if (conflictStorageKey && typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(conflictStorageKey, JSON.stringify(result.state));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error('Cherry-pick continue failed', { description: message });
|
||||
}
|
||||
}, [ui, repoRoot, sourceBranch, targetBranch, onRefresh, conflictStorageKey]);
|
||||
|
||||
if (!repoRoot || !sourceBranch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 h-10 hover:bg-transparent">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<RiSplitCellsHorizontal className="size-4 text-muted-foreground" />
|
||||
<h3 className="typography-ui-header font-semibold text-foreground truncate">Re-integrate commits</h3>
|
||||
{ui.kind === 'ready' && ui.plan.commits.length > 0 ? (
|
||||
<span className="typography-meta text-muted-foreground truncate">{ui.plan.commits.length} to move</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{ui.kind === 'loading' || ui.kind === 'running' ? (
|
||||
<RiLoader4Line className="size-4 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="border-t border-border/40">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label text-foreground">Move commits</div>
|
||||
<div className="typography-micro text-muted-foreground truncate">
|
||||
{sourceBranch} → {targetBranch}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
Target
|
||||
<span className="max-w-[160px] truncate font-mono text-xs text-muted-foreground">{targetBranch}</span>
|
||||
<RiArrowDownSLine className="size-4 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-72 p-0 max-h-(--radix-dropdown-menu-content-available-height) flex flex-col overflow-hidden"
|
||||
>
|
||||
<Command className="h-full min-h-0">
|
||||
<CommandInput placeholder="Search branches..." />
|
||||
<CommandList
|
||||
className="h-full min-h-0"
|
||||
scrollbarClassName="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
|
||||
disableHorizontal
|
||||
>
|
||||
<CommandEmpty>No branches found.</CommandEmpty>
|
||||
<CommandGroup heading="Local branches">
|
||||
{localBranches.map((branch) => (
|
||||
<CommandItem
|
||||
key={branch}
|
||||
value={branch}
|
||||
onSelect={() => {
|
||||
setTargetBranch(branch);
|
||||
persistTarget(branch);
|
||||
}}
|
||||
>
|
||||
{branch}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{ui.kind === 'ready' ? (
|
||||
<Button size="sm" onClick={() => void handleMove()} disabled={!isEligible || ui.plan.commits.length === 0}>
|
||||
Move
|
||||
</Button>
|
||||
) : ui.kind === 'loading' ? (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
Checking…
|
||||
</Button>
|
||||
) : ui.kind === 'running' ? (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
Moving…
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{ui.kind === 'ready' && ui.plan.commits.length === 0 && (
|
||||
<div className="typography-meta text-muted-foreground">No commits to move.</div>
|
||||
)}
|
||||
|
||||
{ui.kind === 'ready' && ui.plan.commits.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="typography-meta text-foreground">
|
||||
Commits to move
|
||||
<span className="text-muted-foreground"> ({ui.plan.commits.length})</span>
|
||||
</div>
|
||||
{commitSummaries.length > 0 && ui.plan.commits.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllCommits((v) => !v)}
|
||||
className="typography-micro text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showAllCommits ? 'Show less' : 'Show all'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{(showAllCommits ? commitSummaries : commitSummaries.slice(0, 5)).map((c) => (
|
||||
<div key={c.sha} className="flex items-baseline gap-2 min-w-0">
|
||||
<span className="font-mono text-xs text-muted-foreground flex-shrink-0">{c.short}</span>
|
||||
<span className="typography-meta text-muted-foreground truncate">{c.subject || c.sha}</span>
|
||||
</div>
|
||||
))}
|
||||
{commitSummaries.length === 0 && (
|
||||
<div className="typography-meta text-muted-foreground">Preview unavailable.</div>
|
||||
)}
|
||||
{ui.plan.commits.length > commitSummaries.length && (
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
Showing first {commitSummaries.length} commits.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ui.kind === 'conflict' && (
|
||||
<div className="rounded-md border border-border/60 bg-background/60 p-3 space-y-2">
|
||||
<div className="typography-meta text-foreground">
|
||||
Conflicts in {ui.details.unmergedFiles.length} files
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground/80">
|
||||
Current commit: <span className="font-mono">{ui.state.currentCommit.slice(0, 7)}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ui.details.unmergedFiles.slice(0, 6).map((file) => (
|
||||
<span key={file} className="font-mono text-xs px-2 py-0.5 rounded bg-muted/40 text-muted-foreground">
|
||||
{file}
|
||||
</span>
|
||||
))}
|
||||
{ui.details.unmergedFiles.length > 6 && (
|
||||
<span className="text-xs text-muted-foreground">+{ui.details.unmergedFiles.length - 6} more</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 py-0 typography-meta" onClick={() => void handleAbort()}>
|
||||
Abort
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-7 px-2 py-0 typography-meta"
|
||||
onClick={() => void handleResolveWithAi({ state: ui.state, details: ui.details })}
|
||||
>
|
||||
Resolve with AI
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 px-2 py-0 typography-meta" onClick={() => void handleContinue()}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -499,6 +499,23 @@ html:not(.dark) .chat-scroll {
|
||||
}
|
||||
}
|
||||
|
||||
/* Commit actions: collapse labels when narrow. */
|
||||
@container commit-actions (max-width: 28rem) {
|
||||
.commit-actions__label--long {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container commit-actions (max-width: 22rem) {
|
||||
.commit-actions__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.commit-actions__btn {
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Text font: IBM Plex Sans */
|
||||
.streamdown-content {
|
||||
font-family: var(--font-sans);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { CommandExecResult, FilesAPI, RuntimeAPIs } from '@/lib/api/types';
|
||||
|
||||
type ExecResult = { success: boolean; results: CommandExecResult[] };
|
||||
|
||||
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || '/api';
|
||||
|
||||
const getBaseUrl = (): string => {
|
||||
if (typeof DEFAULT_BASE_URL === 'string' && DEFAULT_BASE_URL.startsWith('/')) {
|
||||
return DEFAULT_BASE_URL;
|
||||
}
|
||||
return DEFAULT_BASE_URL;
|
||||
};
|
||||
|
||||
function getRuntimeFilesAPI(): FilesAPI | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
if (apis?.files) {
|
||||
return apis.files;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function execCommands(commands: string[], cwd: string): Promise<ExecResult> {
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.execCommands) {
|
||||
return runtimeFiles.execCommands(commands, cwd);
|
||||
}
|
||||
|
||||
const response = await fetch(`${getBaseUrl()}/fs/exec`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ commands, cwd, background: false }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || 'Command exec failed');
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { success?: boolean; results?: CommandExecResult[] }
|
||||
| null;
|
||||
|
||||
return {
|
||||
success: Boolean(payload?.success),
|
||||
results: Array.isArray(payload?.results) ? payload!.results! : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function execCommand(command: string, cwd: string): Promise<CommandExecResult> {
|
||||
const result = await execCommands([command], cwd);
|
||||
const first = result.results[0];
|
||||
if (!first) {
|
||||
return { command, success: result.success };
|
||||
}
|
||||
return first;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { CommandExecResult } from '@/lib/api/types';
|
||||
import { execCommand } from '@/lib/execCommands';
|
||||
|
||||
export type IntegratePlan = {
|
||||
repoRoot: string;
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
commits: string[];
|
||||
};
|
||||
|
||||
export type IntegrateConflictDetails = {
|
||||
statusPorcelain: string;
|
||||
unmergedFiles: string[];
|
||||
diff: string;
|
||||
currentPatchMeta: string;
|
||||
currentPatch: string;
|
||||
};
|
||||
|
||||
export type IntegrateInProgress = {
|
||||
repoRoot: string;
|
||||
tempWorktreePath: string;
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
remainingCommits: string[];
|
||||
currentCommit: string;
|
||||
};
|
||||
|
||||
export type IntegrateResult =
|
||||
| { kind: 'noop'; reason: string }
|
||||
| { kind: 'success'; moved: number }
|
||||
| { kind: 'conflict'; state: IntegrateInProgress; details: IntegrateConflictDetails };
|
||||
|
||||
const shellQuote = (value: string): string => {
|
||||
const v = value.trim();
|
||||
if (!v) return "''";
|
||||
return `'${v.replace(/'/g, `'\\''`)}'`;
|
||||
};
|
||||
|
||||
const trimLines = (value: string | undefined): string[] =>
|
||||
(value || '')
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const isOk = (result: CommandExecResult): boolean => Boolean(result.success);
|
||||
|
||||
const stdoutText = (result: CommandExecResult): string => (result.stdout || '').trim();
|
||||
const stderrText = (result: CommandExecResult): string => (result.stderr || '').trim();
|
||||
|
||||
async function ensureLocalBranch(repoRoot: string, candidate: string): Promise<string> {
|
||||
const raw = candidate.trim();
|
||||
if (!raw || raw === 'HEAD') {
|
||||
return 'HEAD';
|
||||
}
|
||||
|
||||
const hasLocal = await execCommand(
|
||||
`git show-ref --verify --quiet ${shellQuote(`refs/heads/${raw}`)} && echo ok || echo missing`,
|
||||
repoRoot
|
||||
);
|
||||
if (stdoutText(hasLocal) === 'ok') {
|
||||
return raw;
|
||||
}
|
||||
|
||||
// remotes/origin/main -> main (track origin/main)
|
||||
if (raw.startsWith('remotes/')) {
|
||||
const remoteRef = raw.slice('remotes/'.length);
|
||||
const parts = remoteRef.split('/');
|
||||
const remote = parts[0] || 'origin';
|
||||
const name = parts.slice(1).join('/');
|
||||
if (name) {
|
||||
await execCommand(`git branch --track ${shellQuote(name)} ${shellQuote(`${remote}/${name}`)}`, repoRoot);
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
// Try origin/<raw>
|
||||
const remoteCheck = await execCommand(
|
||||
`git show-ref --verify --quiet ${shellQuote(`refs/remotes/origin/${raw}`)} && echo ok || echo missing`,
|
||||
repoRoot
|
||||
);
|
||||
if (stdoutText(remoteCheck) === 'ok') {
|
||||
await execCommand(`git branch --track ${shellQuote(raw)} ${shellQuote(`origin/${raw}`)}`, repoRoot);
|
||||
return raw;
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function computeIntegratePlan(args: {
|
||||
repoRoot: string;
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
}): Promise<IntegratePlan> {
|
||||
const repoRoot = args.repoRoot;
|
||||
const sourceBranch = args.sourceBranch.trim();
|
||||
const targetBranchRaw = args.targetBranch.trim();
|
||||
if (!sourceBranch || !targetBranchRaw) {
|
||||
return { repoRoot, sourceBranch, targetBranch: targetBranchRaw, commits: [] };
|
||||
}
|
||||
|
||||
const targetBranch = await ensureLocalBranch(repoRoot, targetBranchRaw);
|
||||
|
||||
const cherry = await execCommand(`git cherry ${shellQuote(targetBranch)} ${shellQuote(sourceBranch)}`, repoRoot);
|
||||
const cherryLines = trimLines(cherry.stdout);
|
||||
const plus = new Set<string>();
|
||||
for (const line of cherryLines) {
|
||||
const match = line.match(/^\+\s+([0-9a-f]{7,40})\b/i);
|
||||
if (match) {
|
||||
plus.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const revList = await execCommand(
|
||||
`git rev-list --reverse ${shellQuote(`${targetBranch}..${sourceBranch}`)}`,
|
||||
repoRoot
|
||||
);
|
||||
const ordered = trimLines(revList.stdout);
|
||||
const commits = ordered.filter((sha) => plus.has(sha));
|
||||
|
||||
return { repoRoot, sourceBranch, targetBranch, commits };
|
||||
}
|
||||
|
||||
async function createTempWorktree(repoRoot: string, targetBranch: string): Promise<string> {
|
||||
const tmp = await execCommand(
|
||||
'mkdir -p "$HOME/.config/openchamber/tmp" && mktemp -d "$HOME/.config/openchamber/tmp/oc-integrate-XXXXXX"',
|
||||
repoRoot
|
||||
);
|
||||
const tmpDir = stdoutText(tmp);
|
||||
if (!tmpDir) {
|
||||
throw new Error(stderrText(tmp) || 'Failed to create temp directory');
|
||||
}
|
||||
const add = await execCommand(
|
||||
`git worktree add --force ${shellQuote(tmpDir)} ${shellQuote(targetBranch)}`,
|
||||
repoRoot
|
||||
);
|
||||
if (!isOk(add)) {
|
||||
throw new Error(stderrText(add) || 'Failed to create temp worktree');
|
||||
}
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
async function removeTempWorktree(repoRoot: string, tmpDir: string): Promise<void> {
|
||||
await execCommand(`git worktree remove --force ${shellQuote(tmpDir)}`, repoRoot).catch(() => undefined);
|
||||
await execCommand('git worktree prune', repoRoot).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function maybeFastForwardUpstream(tmpDir: string): Promise<void> {
|
||||
const upstream = await execCommand('git rev-parse --abbrev-ref --symbolic-full-name @{u}', tmpDir);
|
||||
const upstreamRef = stdoutText(upstream);
|
||||
if (!upstreamRef) {
|
||||
return;
|
||||
}
|
||||
await execCommand('git fetch', tmpDir);
|
||||
const ff = await execCommand(`git merge --ff-only ${shellQuote(upstreamRef)}`, tmpDir);
|
||||
if (!isOk(ff)) {
|
||||
throw new Error(stderrText(ff) || 'Fast-forward failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function collectConflictDetails(tmpDir: string): Promise<IntegrateConflictDetails> {
|
||||
const status = await execCommand('git status --porcelain', tmpDir);
|
||||
const unmerged = await execCommand('git diff --name-only --diff-filter=U', tmpDir);
|
||||
const diff = await execCommand('git diff', tmpDir);
|
||||
const meta = await execCommand('git show --no-patch --pretty=fuller CHERRY_PICK_HEAD', tmpDir);
|
||||
const patch = await execCommand('git show CHERRY_PICK_HEAD', tmpDir);
|
||||
|
||||
return {
|
||||
statusPorcelain: status.stdout || '',
|
||||
unmergedFiles: trimLines(unmerged.stdout),
|
||||
diff: diff.stdout || diff.stderr || '',
|
||||
currentPatchMeta: meta.stdout || meta.stderr || '',
|
||||
currentPatch: patch.stdout || patch.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function getIntegrateConflictDetails(tmpDir: string): Promise<IntegrateConflictDetails> {
|
||||
return collectConflictDetails(tmpDir);
|
||||
}
|
||||
|
||||
export async function isCherryPickInProgress(tmpDir: string): Promise<boolean> {
|
||||
const head = await execCommand('git rev-parse --verify --quiet CHERRY_PICK_HEAD && echo yes || echo no', tmpDir);
|
||||
return stdoutText(head) === 'yes';
|
||||
}
|
||||
|
||||
export async function integrateWorktreeCommits(plan: IntegratePlan): Promise<IntegrateResult> {
|
||||
if (plan.commits.length === 0) {
|
||||
return { kind: 'noop', reason: 'No commits to move' };
|
||||
}
|
||||
|
||||
const tmpDir = await createTempWorktree(plan.repoRoot, plan.targetBranch);
|
||||
|
||||
let remaining: string[] = [];
|
||||
try {
|
||||
await maybeFastForwardUpstream(tmpDir);
|
||||
|
||||
const clean = await execCommand('git status --porcelain', tmpDir);
|
||||
if (stdoutText(clean)) {
|
||||
throw new Error('Target branch has local changes; abort integration and retry');
|
||||
}
|
||||
|
||||
remaining = [...plan.commits];
|
||||
while (remaining.length > 0) {
|
||||
const sha = remaining[0];
|
||||
const pick = await execCommand(`git cherry-pick ${shellQuote(sha)}`, tmpDir);
|
||||
if (isOk(pick)) {
|
||||
remaining.shift();
|
||||
continue;
|
||||
}
|
||||
|
||||
const unmerged = await execCommand('git diff --name-only --diff-filter=U', tmpDir);
|
||||
const unmergedFiles = trimLines(unmerged.stdout);
|
||||
if (unmergedFiles.length > 0) {
|
||||
const details = await collectConflictDetails(tmpDir);
|
||||
return {
|
||||
kind: 'conflict',
|
||||
state: {
|
||||
repoRoot: plan.repoRoot,
|
||||
tempWorktreePath: tmpDir,
|
||||
sourceBranch: plan.sourceBranch,
|
||||
targetBranch: plan.targetBranch,
|
||||
remainingCommits: remaining,
|
||||
currentCommit: sha,
|
||||
},
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(stderrText(pick) || 'Cherry-pick failed');
|
||||
}
|
||||
|
||||
await removeTempWorktree(plan.repoRoot, tmpDir);
|
||||
return { kind: 'success', moved: plan.commits.length };
|
||||
} catch (e) {
|
||||
// Cleanup on any non-conflict error.
|
||||
await removeTempWorktree(plan.repoRoot, tmpDir).catch(() => undefined);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function abortIntegrate(state: IntegrateInProgress): Promise<void> {
|
||||
await execCommand('git cherry-pick --abort', state.tempWorktreePath).catch(() => undefined);
|
||||
await removeTempWorktree(state.repoRoot, state.tempWorktreePath);
|
||||
}
|
||||
|
||||
export async function continueIntegrate(state: IntegrateInProgress): Promise<IntegrateResult> {
|
||||
const cont = await execCommand('git cherry-pick --continue', state.tempWorktreePath);
|
||||
if (!isOk(cont)) {
|
||||
const unmerged = await execCommand('git diff --name-only --diff-filter=U', state.tempWorktreePath);
|
||||
const unmergedFiles = trimLines(unmerged.stdout);
|
||||
if (unmergedFiles.length > 0) {
|
||||
const details = await collectConflictDetails(state.tempWorktreePath);
|
||||
return { kind: 'conflict', state, details };
|
||||
}
|
||||
throw new Error(stderrText(cont) || 'Cherry-pick continue failed');
|
||||
}
|
||||
|
||||
const tmpDir = state.tempWorktreePath;
|
||||
const remaining = [...state.remainingCommits];
|
||||
if (remaining.length > 0 && remaining[0] === state.currentCommit) {
|
||||
remaining.shift();
|
||||
}
|
||||
|
||||
const still = [...remaining];
|
||||
while (still.length > 0) {
|
||||
const sha = still[0];
|
||||
const pick = await execCommand(`git cherry-pick ${shellQuote(sha)}`, tmpDir);
|
||||
if (isOk(pick)) {
|
||||
still.shift();
|
||||
continue;
|
||||
}
|
||||
const unmerged = await execCommand('git diff --name-only --diff-filter=U', tmpDir);
|
||||
const unmergedFiles = trimLines(unmerged.stdout);
|
||||
if (unmergedFiles.length > 0) {
|
||||
const details = await collectConflictDetails(tmpDir);
|
||||
return {
|
||||
kind: 'conflict',
|
||||
state: {
|
||||
repoRoot: state.repoRoot,
|
||||
tempWorktreePath: tmpDir,
|
||||
sourceBranch: state.sourceBranch,
|
||||
targetBranch: state.targetBranch,
|
||||
remainingCommits: still,
|
||||
currentCommit: sha,
|
||||
},
|
||||
details,
|
||||
};
|
||||
}
|
||||
throw new Error(stderrText(pick) || 'Cherry-pick failed');
|
||||
}
|
||||
|
||||
await removeTempWorktree(state.repoRoot, state.tempWorktreePath);
|
||||
return { kind: 'success', moved: remaining.length };
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { isVSCodeRuntime } from './desktop';
|
||||
type ProjectRef = { id: string; path: string };
|
||||
|
||||
const CONFIG_FILENAME = 'openchamber.json';
|
||||
// LEGACY_PROJECT_CONFIG: legacy per-project config root inside repo.
|
||||
const LEGACY_CONFIG_DIR = '.openchamber';
|
||||
const USER_CONFIG_DIR_SEGMENTS = ['.config', 'openchamber'];
|
||||
const USER_PROJECTS_DIR_SEGMENTS = ['.config', 'openchamber', 'projects'];
|
||||
|
||||
@@ -96,9 +96,14 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
|
||||
startPoint,
|
||||
});
|
||||
|
||||
const createdMetadata = {
|
||||
...metadata,
|
||||
createdFromBranch: startPoint ?? 'HEAD',
|
||||
};
|
||||
|
||||
// Get worktree status
|
||||
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
||||
const createdMetadata = status ? { ...metadata, status } : metadata;
|
||||
const createdMetadataWithStatus = status ? { ...createdMetadata, status } : createdMetadata;
|
||||
|
||||
// Create the session
|
||||
const sessionStore = useSessionStore.getState();
|
||||
@@ -117,7 +122,7 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
|
||||
const agents = configState.agents;
|
||||
sessionStore.initializeNewOpenChamberSession(session.id, agents);
|
||||
sessionStore.setSessionDirectory(session.id, metadata.path);
|
||||
sessionStore.setWorktreeMetadata(session.id, createdMetadata);
|
||||
sessionStore.setWorktreeMetadata(session.id, createdMetadataWithStatus);
|
||||
|
||||
// Apply default agent and model settings
|
||||
try {
|
||||
@@ -263,9 +268,14 @@ export async function createWorktreeSessionForBranch(
|
||||
startPoint: branchName,
|
||||
});
|
||||
|
||||
const createdMetadata = {
|
||||
...metadata,
|
||||
createdFromBranch: branchName,
|
||||
};
|
||||
|
||||
// Get worktree status
|
||||
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
||||
const createdMetadata = status ? { ...metadata, status } : metadata;
|
||||
const createdMetadataWithStatus = status ? { ...createdMetadata, status } : createdMetadata;
|
||||
|
||||
// Create the session
|
||||
const sessionStore = useSessionStore.getState();
|
||||
@@ -284,7 +294,7 @@ export async function createWorktreeSessionForBranch(
|
||||
const agents = configState.agents;
|
||||
sessionStore.initializeNewOpenChamberSession(session.id, agents);
|
||||
sessionStore.setSessionDirectory(session.id, metadata.path);
|
||||
sessionStore.setWorktreeMetadata(session.id, createdMetadata);
|
||||
sessionStore.setWorktreeMetadata(session.id, createdMetadataWithStatus);
|
||||
|
||||
// Apply default agent and model settings
|
||||
try {
|
||||
@@ -431,8 +441,13 @@ export async function createWorktreeSessionForNewBranch(
|
||||
allowSuffix,
|
||||
});
|
||||
|
||||
const createdMetadata = {
|
||||
...metadata,
|
||||
createdFromBranch: start,
|
||||
};
|
||||
|
||||
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
||||
const createdMetadata = status ? { ...metadata, status } : metadata;
|
||||
const createdMetadataWithStatus = status ? { ...createdMetadata, status } : createdMetadata;
|
||||
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||
@@ -444,7 +459,7 @@ export async function createWorktreeSessionForNewBranch(
|
||||
const configState = useConfigStore.getState();
|
||||
sessionStore.initializeNewOpenChamberSession(session.id, configState.agents);
|
||||
sessionStore.setSessionDirectory(session.id, metadata.path);
|
||||
sessionStore.setWorktreeMetadata(session.id, createdMetadata);
|
||||
sessionStore.setWorktreeMetadata(session.id, createdMetadataWithStatus);
|
||||
|
||||
// Apply default agent/model/variant settings (reuse same logic as createWorktreeSessionForBranch)
|
||||
try {
|
||||
|
||||
@@ -574,7 +574,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
}
|
||||
});
|
||||
|
||||
// Check if .openchamber directory exists before trying to list it
|
||||
// LEGACY_WORKTREES: check if .openchamber directory exists before listing it
|
||||
// LEGACY_WORKTREES: filesystem scan fallback for legacy <project>/.openchamber/*
|
||||
const projectEntriesList = await opencodeClient.listLocalDirectory(normalizedProject);
|
||||
const worktreeDirExists = projectEntriesList.some(
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
// LEGACY_WORKTREES: legacy worktree root inside project.
|
||||
const OPENCHAMBER_DIR = '.openchamber';
|
||||
|
||||
const resolveProjectDirectory = (currentDirectory: string | null | undefined): string | null => {
|
||||
|
||||
@@ -167,6 +167,11 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
startPoint: startPoint ?? null,
|
||||
});
|
||||
|
||||
const enrichedMetadata = {
|
||||
...worktreeMetadata,
|
||||
createdFromBranch: startPoint ?? 'HEAD',
|
||||
};
|
||||
|
||||
// Session title format: groupSlug/provider/model (or groupSlug/provider/model/index for duplicates)
|
||||
const sessionTitle = count > 1
|
||||
? `${groupSlug}/${model.providerID}/${model.modelID}/${index}`
|
||||
@@ -177,7 +182,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
() => opencodeClient.createSession({ title: sessionTitle })
|
||||
);
|
||||
|
||||
useSessionStore.getState().setWorktreeMetadata(session.id, worktreeMetadata);
|
||||
useSessionStore.getState().setWorktreeMetadata(session.id, enrichedMetadata);
|
||||
|
||||
createdRuns.push({
|
||||
sessionId: session.id,
|
||||
|
||||
@@ -18,6 +18,12 @@ export interface WorktreeMetadata {
|
||||
/** SDK worktree name (slug), if available. */
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* Branch/ref this worktree was created from (intended integration target).
|
||||
* For SDK worktrees this is typically the user-selected base branch.
|
||||
*/
|
||||
createdFromBranch?: string;
|
||||
|
||||
relativePath?: string;
|
||||
|
||||
status?: {
|
||||
|
||||
Reference in New Issue
Block a user