fix(ui): route answer worktrees from source session
This commit is contained in:
@@ -56,6 +56,7 @@ import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedC
|
||||
import { useProviderLogo } from '@/hooks/useProviderLogo';
|
||||
import { getAgentColor } from '@/lib/agentColors';
|
||||
import { isCapacitorMobileApp } from '@/apps/mobileNativeChrome';
|
||||
import { WorktreeRequiresGitRepositoryError } from '@/lib/worktrees/worktreeCreate';
|
||||
|
||||
|
||||
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
|
||||
@@ -1331,17 +1332,14 @@ const AssistantMessageBody = React.memo(({
|
||||
const effectiveStreamPhase: StreamPhase = hasStopFinish ? 'completed' : streamPhase;
|
||||
|
||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const currentProjectRef = React.useMemo(() => {
|
||||
if (!canUseProjectPlanActions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionProjectRef = React.useMemo(() => {
|
||||
const directory = effectiveDirectory
|
||||
?? (currentSessionId ? getDirectoryForSession(currentSessionId) : null)
|
||||
?? '';
|
||||
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory);
|
||||
return resolved ? { id: resolved.id, path: resolved.path } : null;
|
||||
}, [availableWorktreesByProject, canUseProjectPlanActions, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]);
|
||||
}, [availableWorktreesByProject, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]);
|
||||
const currentProjectRef = canUseProjectPlanActions ? sessionProjectRef : null;
|
||||
|
||||
const isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => {
|
||||
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
|
||||
@@ -1377,28 +1375,48 @@ const AssistantMessageBody = React.memo(({
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
if (!createSessionFromAssistantMessage || !assistantPlanText.trim()) {
|
||||
if (!assistantPlanText.trim()) {
|
||||
return;
|
||||
}
|
||||
setIsForkDialogOpen(true);
|
||||
},
|
||||
[createSessionFromAssistantMessage, assistantPlanText]
|
||||
[assistantPlanText]
|
||||
);
|
||||
|
||||
const handleConfirmFork = React.useCallback(
|
||||
async (execution: ForkSessionExecution) => {
|
||||
if (!createSessionFromAssistantMessage) {
|
||||
return;
|
||||
}
|
||||
setIsForkSubmitting(true);
|
||||
try {
|
||||
await createSessionFromAssistantMessage(messageId, execution);
|
||||
if (!sessionId) {
|
||||
throw new Error('Source session is unavailable');
|
||||
}
|
||||
const sourceDirectory = effectiveDirectory ?? getDirectoryForSession(sessionId);
|
||||
if (!sourceDirectory) {
|
||||
throw new Error('Source session directory is unavailable');
|
||||
}
|
||||
await createSessionFromAssistantMessage({
|
||||
sessionId,
|
||||
directory: sourceDirectory,
|
||||
text: assistantPlanText,
|
||||
}, execution);
|
||||
setIsForkDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to start a session from an assistant message:', error);
|
||||
if (error instanceof WorktreeRequiresGitRepositoryError) {
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo'));
|
||||
return;
|
||||
}
|
||||
|
||||
const description = error instanceof Error ? error.message : undefined;
|
||||
toast.error(
|
||||
t('rightSidebar.contextNotesTodo.toast.createSessionFailed'),
|
||||
description ? { description } : undefined
|
||||
);
|
||||
} finally {
|
||||
setIsForkSubmitting(false);
|
||||
}
|
||||
},
|
||||
[createSessionFromAssistantMessage, messageId]
|
||||
[assistantPlanText, createSessionFromAssistantMessage, effectiveDirectory, getDirectoryForSession, sessionId, t]
|
||||
);
|
||||
|
||||
const handleForkMultiRunClick = React.useCallback(
|
||||
@@ -2136,6 +2154,8 @@ const AssistantMessageBody = React.memo(({
|
||||
open={isForkDialogOpen}
|
||||
onOpenChange={setIsForkDialogOpen}
|
||||
projectDirectory={effectiveDirectory ?? null}
|
||||
sourceSessionId={sessionId ?? null}
|
||||
worktreeProjectDirectory={sessionProjectRef?.path ?? null}
|
||||
submitting={isForkSubmitting}
|
||||
onConfirm={handleConfirmFork}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,9 @@ import { isPrimaryMode } from '@/components/chat/mobileControlsUtils';
|
||||
import { EXECUTION_FORK_DEFAULT_INSTRUCTIONS, EXECUTION_FORK_GOAL_INSTRUCTIONS } from '@/lib/messages/executionMeta';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useGitStore, useIsGitRepo } from '@/stores/useGitStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
export type ForkSessionExecution = {
|
||||
providerID: string;
|
||||
@@ -32,13 +35,22 @@ type ForkSessionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectDirectory: string | null;
|
||||
sourceSessionId: string | null;
|
||||
worktreeProjectDirectory: string | null;
|
||||
submitting?: boolean;
|
||||
onConfirm: (execution: ForkSessionExecution) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function ForkSessionDialog(props: ForkSessionDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { open, onOpenChange, projectDirectory, submitting = false, onConfirm } = props;
|
||||
const { open, onOpenChange, projectDirectory, sourceSessionId, worktreeProjectDirectory, submitting = false, onConfirm } = props;
|
||||
const metadataProjectDirectory = useSessionUIStore((state) => (
|
||||
sourceSessionId ? state.worktreeMetadata.get(sourceSessionId)?.projectDirectory ?? null : null
|
||||
));
|
||||
const resolvedWorktreeProjectDirectory = metadataProjectDirectory ?? worktreeProjectDirectory;
|
||||
const git = useRuntimeAPIs().git;
|
||||
const isGitRepository = useIsGitRepo(resolvedWorktreeProjectDirectory);
|
||||
const ensureGitStatus = useGitStore((state) => state.ensureStatus);
|
||||
|
||||
const loadProviders = useConfigStore((state) => state.loadProviders);
|
||||
const loadConfigAgents = useConfigStore((state) => state.loadAgents);
|
||||
@@ -56,7 +68,7 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) {
|
||||
const [instructions, setInstructions] = React.useState(EXECUTION_FORK_DEFAULT_INSTRUCTIONS);
|
||||
const [createWorktree, setCreateWorktree] = React.useState(false);
|
||||
const [runAsGoal, setRunAsGoal] = React.useState(false);
|
||||
const showCreateWorktree = React.useMemo(() => !isVSCodeRuntime(), []);
|
||||
const showCreateWorktree = !isVSCodeRuntime() && isGitRepository === true;
|
||||
// The goal loop lives in the web server; VS Code only renders goal state.
|
||||
const showRunAsGoal = React.useMemo(() => !isVSCodeRuntime(), []);
|
||||
|
||||
@@ -79,6 +91,11 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) {
|
||||
void loadAgentsStoreAgents();
|
||||
}, [open, loadProviders, loadConfigAgents, loadAgentsStoreAgents, projectDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !resolvedWorktreeProjectDirectory || !git) return;
|
||||
void ensureGitStatus(resolvedWorktreeProjectDirectory, git);
|
||||
}, [ensureGitStatus, git, open, resolvedWorktreeProjectDirectory]);
|
||||
|
||||
// Reset only when the dialog transitions to open. Reading the store snapshot
|
||||
// here (instead of subscribing) avoids clobbering in-progress user edits when
|
||||
// the config store refreshes in the background while the dialog is open.
|
||||
|
||||
@@ -15,9 +15,11 @@ let gitStatus: {
|
||||
behind: number;
|
||||
} | null = null;
|
||||
let branchTracking: MockBranchTracking = {};
|
||||
let isGitRepository = true;
|
||||
const createdPayloads: CreateWorktreeArgs[] = [];
|
||||
|
||||
mock.module('@/lib/gitApi', () => ({
|
||||
checkIsGitRepository: () => Promise.resolve(isGitRepository),
|
||||
getGitStatus: () => (gitStatus ? Promise.resolve(gitStatus) : Promise.reject(new Error('no status'))),
|
||||
getGitBranches: () => Promise.resolve({
|
||||
all: [],
|
||||
@@ -52,7 +54,11 @@ mock.module('@/lib/worktrees/worktreeManager', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { createWorktreeWithDefaults, withWorktreeRemoteStartRef } = await import('./worktreeCreate');
|
||||
const {
|
||||
createWorktreeWithDefaults,
|
||||
withWorktreeRemoteStartRef,
|
||||
WorktreeRequiresGitRepositoryError,
|
||||
} = await import('./worktreeCreate');
|
||||
|
||||
const baseArgs = (overrides: CreateWorktreeArgs = {}): CreateWorktreeArgs => ({
|
||||
preferredName: 'openchamber/feature',
|
||||
@@ -168,9 +174,17 @@ describe('createWorktreeWithDefaults remote source integration', () => {
|
||||
projectRoot = '/repo';
|
||||
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 0 };
|
||||
branchTracking = { main: 'origin/main' };
|
||||
isGitRepository = true;
|
||||
createdPayloads.length = 0;
|
||||
});
|
||||
|
||||
test('rejects a non-Git project before asking the runtime to create a worktree', async () => {
|
||||
isGitRepository = false;
|
||||
|
||||
await expect(createWorktreeWithDefaults(project, baseArgs())).rejects.toThrow(WorktreeRequiresGitRepositoryError);
|
||||
expect(createdPayloads).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('sets the new branch\'s own upstream when using the tracked remote source', async () => {
|
||||
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 };
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { getGitBranches, getGitStatus } from '@/lib/gitApi';
|
||||
import { checkIsGitRepository, getGitBranches, getGitStatus } from '@/lib/gitApi';
|
||||
import type { CreateWorktreeArgs, ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { createWorktree } from '@/lib/worktrees/worktreeManager';
|
||||
import { getRootBranch, resolveProjectRoot } from '@/lib/worktrees/worktreeStatus';
|
||||
|
||||
export class WorktreeRequiresGitRepositoryError extends Error {
|
||||
constructor() {
|
||||
super('Worktree creation requires a Git repository');
|
||||
this.name = 'WorktreeRequiresGitRepositoryError';
|
||||
}
|
||||
}
|
||||
|
||||
const parseTrackingRef = (tracking: string | null | undefined): { remote: string; branch: string } | null => {
|
||||
const value = String(tracking || '').trim().replace(/^remotes\//, '');
|
||||
if (!value) {
|
||||
@@ -162,6 +169,10 @@ export const createWorktreeWithDefaults = async (
|
||||
args: CreateWorktreeArgs,
|
||||
options?: { resolvedRootTrackingRemote?: string | null }
|
||||
) => {
|
||||
const isGitRepository = await checkIsGitRepository(project.path);
|
||||
if (!isGitRepository) {
|
||||
throw new WorktreeRequiresGitRepositoryError();
|
||||
}
|
||||
const remoteArgs = await withWorktreeRemoteStartRef(project, args);
|
||||
const resolvedArgs = await withWorktreeUpstreamDefaults(project.path, remoteArgs, options);
|
||||
return createWorktree(project, resolvedArgs);
|
||||
|
||||
@@ -290,6 +290,7 @@ Rules:
|
||||
8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
|
||||
9. `SessionLiveActivity` has three answers and `unknown` is never `idle`. `getSessionLiveActivity` reports `active` when any child store or the global session-status index holds a non-idle status, `idle` only when a child store actually covers the session's directory, and `unknown` otherwise — child stores are evicted for background directories, and the global index keeps only non-idle entries, so absence of a status is not proof of idleness. Callers that gate a destructive action (worktree moves) must refuse on `unknown`.
|
||||
10. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo.
|
||||
11. Starting a session from an assistant answer carries the source session ID, rendered directory, and answer text into the action. It must not rediscover that context from the globally active child store or the OpenCode client's fallback directory: the visible session may belong to an existing worktree while the active provider directory points elsewhere. New isolated worktrees resolve their registered parent project from that captured directory, preferring recorded worktree metadata when available. The dialog offers creation only after the project root is confirmed as a Git repository, and the creation boundary repeats that check so stale or bypassed UI state cannot run Git commands against a non-repository directory; failures leave the dialog open and visible.
|
||||
|
||||
Examples of global-store updates performed in `session-actions.ts`:
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ const createSessionCalls: Array<{ title?: string; directory: string | null; pare
|
||||
const permissionAutoAcceptCalls: Array<[string, boolean]> = []
|
||||
const savedVariantCalls: Array<string | undefined> = []
|
||||
let configVariantOverride: string | null | undefined
|
||||
let projects: Array<{ id: string; path: string; label: string }> = []
|
||||
const createdWorktreeProjects: Array<{ id: string; path: string }> = []
|
||||
// Sync's session→directory index. `createSession` writes it, and directory
|
||||
// resolution reads it as the authoritative source, so the mock has to keep one.
|
||||
const sessionDirectoryRegistry = new Map<string, string>()
|
||||
@@ -111,7 +113,7 @@ mock.module("@/stores/useConfigStore", () => ({
|
||||
mock.module("@/stores/useProjectsStore", () => ({
|
||||
useProjectsStore: {
|
||||
getState: () => ({
|
||||
projects: [],
|
||||
projects,
|
||||
activeProjectId: null,
|
||||
getActiveProject: () => null,
|
||||
}),
|
||||
@@ -127,6 +129,12 @@ mock.module("@/stores/useDirectoryStore", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/stores/useSessionGoalArmStore", () => ({
|
||||
useSessionGoalArmStore: {
|
||||
getState: () => ({ setArmed: () => undefined }),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/stores/useGlobalSessionsStore", () => ({
|
||||
useGlobalSessionsStore: {
|
||||
getState: () => ({
|
||||
@@ -304,6 +312,33 @@ mock.module("../session-actions", () => ({
|
||||
abortCurrentOperation: mock(async () => undefined),
|
||||
}))
|
||||
|
||||
mock.module("@/lib/git/branchNameGenerator", () => ({
|
||||
generateBranchName: () => "generated-branch",
|
||||
}))
|
||||
|
||||
mock.module("@/lib/openchamberConfig", () => ({
|
||||
getWorktreeSetupCommands: async () => [],
|
||||
getWorktreeSetupWaitEnabled: async () => false,
|
||||
}))
|
||||
|
||||
mock.module("@/lib/worktrees/worktreeBootstrap", () => ({
|
||||
waitForWorktreeBootstrap: async () => undefined,
|
||||
}))
|
||||
|
||||
mock.module("@/lib/worktrees/worktreeCreate", () => ({
|
||||
createWorktreeWithDefaults: async (project: { id: string; path: string }) => {
|
||||
createdWorktreeProjects.push(project)
|
||||
return {
|
||||
source: "sdk",
|
||||
name: "generated-branch",
|
||||
path: "/worktrees/generated-branch",
|
||||
projectDirectory: project.path,
|
||||
branch: "generated-branch",
|
||||
label: "generated-branch",
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const { materializeOpenDraftSession, useSessionUIStore } = await import("../session-ui-store")
|
||||
|
||||
describe("issue 2039 draft auto-accept", () => {
|
||||
@@ -500,3 +535,94 @@ describe("issue 2039 draft auto-accept", () => {
|
||||
expect(useSessionUIStore.getState().getDirectoryForSession(sessionId)).toBe("/canonical/worktree")
|
||||
})
|
||||
})
|
||||
|
||||
describe("assistant answer worktree routing", () => {
|
||||
test("reports session creation failure instead of completing silently", async () => {
|
||||
const state = useSessionUIStore.getState()
|
||||
const createFromAssistantMessage = state.createSessionFromAssistantMessage
|
||||
const originalCreateSession = state.createSession
|
||||
|
||||
useSessionUIStore.setState({
|
||||
createSession: async () => null,
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(createFromAssistantMessage({
|
||||
sessionId: "source-session",
|
||||
directory: "/repo",
|
||||
text: "Implement the plan",
|
||||
}, {
|
||||
providerID: "provider",
|
||||
modelID: "model",
|
||||
variant: "",
|
||||
agent: "build",
|
||||
instructions: "Follow the answer",
|
||||
})).rejects.toThrow("Failed to create session")
|
||||
} finally {
|
||||
useSessionUIStore.setState({ createSession: originalCreateSession })
|
||||
}
|
||||
})
|
||||
|
||||
test("creates a sibling worktree from the captured source worktree directory", async () => {
|
||||
projects = [
|
||||
{ id: "project", path: "/repo", label: "Repo" },
|
||||
{ id: "source-worktree", path: "/worktrees/source", label: "Source worktree" },
|
||||
]
|
||||
createdWorktreeProjects.length = 0
|
||||
const sourceWorktree = {
|
||||
path: "/worktrees/source",
|
||||
projectDirectory: "/repo",
|
||||
branch: "source",
|
||||
label: "source",
|
||||
}
|
||||
const state = useSessionUIStore.getState()
|
||||
const createFromAssistantMessage = state.createSessionFromAssistantMessage
|
||||
const originalCreateSession = state.createSession
|
||||
const originalSendMessage = state.sendMessage
|
||||
const originalWorktreeMetadata = state.worktreeMetadata
|
||||
let createdDirectory: string | null | undefined
|
||||
|
||||
useSessionUIStore.setState({
|
||||
availableWorktreesByProject: new Map([["/repo", [sourceWorktree]]]),
|
||||
worktreeMetadata: new Map([["source-session", sourceWorktree]]),
|
||||
createSession: async (_title, directory) => {
|
||||
createdDirectory = directory
|
||||
return {
|
||||
id: "created-session",
|
||||
slug: "created-session",
|
||||
projectID: "project",
|
||||
directory: directory ?? "",
|
||||
title: "Created session",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
},
|
||||
sendMessage: async () => undefined,
|
||||
})
|
||||
|
||||
try {
|
||||
await createFromAssistantMessage({
|
||||
sessionId: "source-session",
|
||||
directory: "/worktrees/source",
|
||||
text: "Implement the plan",
|
||||
}, {
|
||||
providerID: "provider",
|
||||
modelID: "model",
|
||||
variant: "",
|
||||
agent: "build",
|
||||
instructions: "Follow the answer",
|
||||
createWorktree: true,
|
||||
})
|
||||
} finally {
|
||||
useSessionUIStore.setState({
|
||||
createSession: originalCreateSession,
|
||||
sendMessage: originalSendMessage,
|
||||
worktreeMetadata: originalWorktreeMetadata,
|
||||
})
|
||||
projects = []
|
||||
}
|
||||
|
||||
expect(createdWorktreeProjects).toEqual([{ id: "project", path: "/repo" }])
|
||||
expect(createdDirectory).toBe("/worktrees/generated-branch")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
import type { ContextPartMetadata } from "@/lib/messages/contextParts"
|
||||
import { create } from "zustand"
|
||||
import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Session, Part, TextPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes"
|
||||
import type { WorktreeMetadata } from "@/types/worktree"
|
||||
import { opencodeClient } from "@/lib/opencode/client"
|
||||
@@ -33,7 +33,6 @@ import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
|
||||
import { normalizePath } from "@/lib/pathNormalization"
|
||||
import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryPath, warmChatsRootDirectory } from "@/lib/chatDirectories"
|
||||
import { isVSCodeRuntime } from "@/lib/desktop"
|
||||
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
|
||||
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
|
||||
import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice"
|
||||
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
|
||||
@@ -252,6 +251,12 @@ type AssistantMessageSessionExecution = {
|
||||
runAsGoal?: boolean
|
||||
}
|
||||
|
||||
type AssistantMessageSessionSource = {
|
||||
sessionId: string
|
||||
directory: string
|
||||
text: string
|
||||
}
|
||||
|
||||
function notifyMessageSent(sessionId: string): void {
|
||||
runtimeFetch(`/api/sessions/${sessionId}/message-sent`, { method: "POST" })
|
||||
.catch(() => { /* ignore */ })
|
||||
@@ -385,7 +390,7 @@ export type SessionUIState = {
|
||||
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>
|
||||
handleSlashUndo: (sessionId: string) => Promise<void>
|
||||
handleSlashRedo: (sessionId: string, options?: { fullUnrevert?: boolean }) => Promise<void>
|
||||
createSessionFromAssistantMessage: (sourceMessageId: string, execution: AssistantMessageSessionExecution) => Promise<void>
|
||||
createSessionFromAssistantMessage: (source: AssistantMessageSessionSource, execution: AssistantMessageSessionExecution) => Promise<void>
|
||||
|
||||
// Data access helpers (read from sync)
|
||||
getSessionsByDirectory: (directory: string) => Session[]
|
||||
@@ -1977,47 +1982,26 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createSessionFromAssistantMessage — reads from sync
|
||||
// createSessionFromAssistantMessage — uses the rendered source context
|
||||
// ---------------------------------------------------------------------------
|
||||
createSessionFromAssistantMessage: async (sourceMessageId, execution) => {
|
||||
if (!sourceMessageId) return
|
||||
createSessionFromAssistantMessage: async (source, execution) => {
|
||||
if (!source.sessionId) return
|
||||
if (!execution?.instructions?.trim()) return
|
||||
|
||||
// Find which session this message belongs to by scanning sync state
|
||||
const state = getDirectoryState()
|
||||
if (!state) return
|
||||
|
||||
let sourceSessionId: string | undefined
|
||||
let sourceMessage: Message | undefined
|
||||
|
||||
for (const [sid, msgs] of Object.entries(state.message ?? {})) {
|
||||
const found = msgs.find((m) => m.id === sourceMessageId)
|
||||
if (found) {
|
||||
sourceSessionId = sid
|
||||
sourceMessage = found
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceMessage || sourceMessage.role !== "assistant") return
|
||||
|
||||
const sourceParts = getSyncParts(sourceMessageId)
|
||||
const assistantPlanText = flattenAssistantTextParts(sourceParts)
|
||||
const assistantPlanText = source.text
|
||||
if (!assistantPlanText.trim()) return
|
||||
|
||||
const directory = resolveSessionDirectory(
|
||||
sourceSessionId ?? null,
|
||||
(sid) => get().worktreeMetadata.get(sid),
|
||||
)
|
||||
const sourceWorktreeMetadata = sourceSessionId ? get().worktreeMetadata.get(sourceSessionId) : undefined
|
||||
const sourceDirectory = normalizePath(source.directory)
|
||||
if (!sourceDirectory) {
|
||||
throw new Error("Source session directory is unavailable")
|
||||
}
|
||||
const sourceWorktreeMetadata = get().worktreeMetadata.get(source.sessionId)
|
||||
|
||||
const pID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID
|
||||
const mID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID
|
||||
const providerID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID
|
||||
const modelID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID
|
||||
|
||||
if (!pID || !mID) return
|
||||
if (!providerID || !modelID) return
|
||||
|
||||
const sourceDirectory = normalizePath(directory ?? opencodeClient.getDirectory() ?? null)
|
||||
let sessionDirectory = sourceDirectory
|
||||
let sessionDirectory: string | null = sourceDirectory
|
||||
let createdWorktree: WorktreeMetadata | null = null
|
||||
let createdWorktreeProject: { id: string; path: string } | null = null
|
||||
|
||||
@@ -2026,11 +2010,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
const project = resolveProjectForSessionDirectory(
|
||||
projects,
|
||||
get().availableWorktreesByProject,
|
||||
sourceDirectory,
|
||||
sourceWorktreeMetadata?.projectDirectory ?? null,
|
||||
) ?? resolveProjectForSessionDirectory(
|
||||
projects,
|
||||
get().availableWorktreesByProject,
|
||||
sourceWorktreeMetadata?.projectDirectory ?? null,
|
||||
sourceDirectory,
|
||||
)
|
||||
if (!project?.path) {
|
||||
throw new Error("Project is not registered in OpenChamber")
|
||||
@@ -2061,13 +2045,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
}
|
||||
}
|
||||
|
||||
const session = await get().createSession(undefined, sessionDirectory || null, null)
|
||||
const session = await get().createSession(undefined, sessionDirectory, null)
|
||||
if (!session) {
|
||||
if (createdWorktree && createdWorktreeProject) {
|
||||
const { removeProjectWorktree } = await import("@/lib/worktrees/worktreeManager")
|
||||
await removeProjectWorktree(createdWorktreeProject, createdWorktree, { deleteLocalBranch: true }).catch(() => undefined)
|
||||
}
|
||||
return
|
||||
throw new Error("Failed to create session")
|
||||
}
|
||||
|
||||
if (createdWorktree) {
|
||||
@@ -2087,8 +2071,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
|
||||
await get().sendMessage(
|
||||
composeForkSessionMessage(execution.instructions, assistantPlanText),
|
||||
pID,
|
||||
mID,
|
||||
providerID,
|
||||
modelID,
|
||||
execution.agent || undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
Reference in New Issue
Block a user