236 lines
8.3 KiB
TypeScript
236 lines
8.3 KiB
TypeScript
import { toast } from '@/components/ui';
|
|||
|
|
import type { LinearAPI, LinearIssue, LinearIssueComment, LinearMappingResult } from '@/lib/api/types';
|
||
|
|
import type { I18nKey, I18nParams } from '@/lib/i18n';
|
||
|
|
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
||
|
|
import { modelVariantNames } from '@/lib/modelVariants';
|
||
|
|
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||
|
|
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
|
||
|
|
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
||
|
|
import { buildLinkedLinearIssue } from '@/lib/linkedIssues';
|
||
|
|
import { resolveLinearMappedProjectPath } from '@/lib/linearProjectMapping';
|
||
|
|
import { postLinearSessionStarted } from '@/lib/linearSessionStatus';
|
||
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||
|
|
import { useUIStore } from '@/stores/useUIStore';
|
||
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||
|
|
import { useSelectionStore } from '@/sync/selection-store';
|
||
|
|
import * as sessionActions from '@/sync/session-actions';
|
||
|
|
|
||
|
|
type TranslateFn = (key: I18nKey, params?: I18nParams) => string;
|
||
|
|
|
||
|
|
export function buildIssueContextText(args: {
|
||
|
|
issue: LinearIssue;
|
||
|
|
comments: LinearIssueComment[];
|
||
|
|
}): string {
|
||
|
|
const payload = {
|
||
|
|
issue: args.issue,
|
||
|
|
comments: args.comments,
|
||
|
|
};
|
||
|
|
return `Linear issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveDefaultAgentName(): string | undefined {
|
||
|
|
const configState = useConfigStore.getState();
|
||
|
|
const settingsDefaultAgent = configState.settingsDefaultAgent;
|
||
|
|
if (settingsDefaultAgent) {
|
||
|
|
return settingsDefaultAgent;
|
||
|
|
}
|
||
|
|
const visibleAgents = configState.agents.filter((agent) => !agent.hidden);
|
||
|
|
return (
|
||
|
|
configState.currentAgentName
|
||
|
|
|| visibleAgents.find((agent) => agent.mode === 'primary' || !agent.mode)?.name
|
||
|
|
|| visibleAgents[0]?.name
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveDefaultModelSelection(): { providerID: string; modelID: string } | null {
|
||
|
|
const configState = useConfigStore.getState();
|
||
|
|
const settingsDefaultModel = configState.settingsDefaultModel;
|
||
|
|
if (!settingsDefaultModel) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const parsed = parseModelIdentifier(settingsDefaultModel);
|
||
|
|
if (!parsed) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const { providerId: providerID, modelId: modelID } = parsed;
|
||
|
|
|
||
|
|
const modelMetadata = configState.getModelMetadata(providerID, modelID);
|
||
|
|
if (!modelMetadata) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return { providerID, modelID };
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveDefaultVariant(providerID: string, modelID: string): string | undefined {
|
||
|
|
const configState = useConfigStore.getState();
|
||
|
|
const settingsDefaultVariant = configState.settingsDefaultVariant;
|
||
|
|
const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID
|
||
|
|
? configState.currentVariant
|
||
|
|
: undefined;
|
||
|
|
|
||
|
|
const provider = configState.providers.find((entry) => entry.id === providerID);
|
||
|
|
const model = provider?.models.find((entry) => entry.id === modelID);
|
||
|
|
const variantNames = modelVariantNames(model);
|
||
|
|
if (variantNames.length === 0) {
|
||
|
|
return settingsDefaultVariant || currentVariant || undefined;
|
||
|
|
}
|
||
|
|
if (settingsDefaultVariant && variantNames.includes(settingsDefaultVariant)) {
|
||
|
|
return settingsDefaultVariant;
|
||
|
|
}
|
||
|
|
if (currentVariant && variantNames.includes(currentVariant)) {
|
||
|
|
return currentVariant;
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function startLinearIssueSession(args: {
|
||
|
|
linear: LinearAPI | undefined;
|
||
|
|
issueKey: string;
|
||
|
|
createInWorktree: boolean;
|
||
|
|
mapping?: LinearMappingResult | null;
|
||
|
|
onMappingLoaded?: (mapping: LinearMappingResult) => void;
|
||
|
|
onSessionCreated?: () => void;
|
||
|
|
t: TranslateFn;
|
||
|
|
}): Promise<boolean> {
|
||
|
|
const { linear, issueKey, createInWorktree, t } = args;
|
||
|
|
if (!linear?.issueGet || !linear.mappingGet) {
|
||
|
|
toast.error(t('session.linearIssuePicker.error.runtimeUnavailable'));
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
let mappingView = args.mapping;
|
||
|
|
if (!mappingView) {
|
||
|
|
mappingView = await linear.mappingGet();
|
||
|
|
args.onMappingLoaded?.(mappingView);
|
||
|
|
}
|
||
|
|
if (mappingView.connected === false) {
|
||
|
|
toast.error(t('session.linearIssuePicker.error.notConnected'));
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const issueRes = await linear.issueGet(issueKey);
|
||
|
|
if (issueRes.connected === false) {
|
||
|
|
toast.error(t('session.linearIssuePicker.error.notConnected'));
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const issue = issueRes.issue;
|
||
|
|
if (!issue) {
|
||
|
|
toast.error(t('session.linearIssuePicker.error.issueNotFound'));
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const projectDirectory = resolveLinearMappedProjectPath(mappingView, issue.team);
|
||
|
|
if (!projectDirectory) {
|
||
|
|
toast.error(t('session.linearIssuePicker.error.noMappedProject'));
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const comments = issue.comments ?? [];
|
||
|
|
const sessionTitle = `${issue.identifier} ${issue.title}`.trim();
|
||
|
|
const login = issue.assignee?.displayName || issue.assignee?.name;
|
||
|
|
|
||
|
|
const { sessionId, sessionDirectory } = await (async () => {
|
||
|
|
if (createInWorktree) {
|
||
|
|
const preferred = `issue-${issue.identifier}-${generateBranchSlug()}`;
|
||
|
|
const created = await createWorktreeSessionForNewBranch(
|
||
|
|
projectDirectory,
|
||
|
|
preferred,
|
||
|
|
undefined,
|
||
|
|
{ returnAfterDirectoryCreated: true },
|
||
|
|
);
|
||
|
|
if (!created?.id) {
|
||
|
|
throw new Error('Failed to create worktree session');
|
||
|
|
}
|
||
|
|
return { sessionId: created.id, sessionDirectory: created.path };
|
||
|
|
}
|
||
|
|
|
||
|
|
const session = await sessionActions.createSession(sessionTitle, projectDirectory, null);
|
||
|
|
if (!session?.id) {
|
||
|
|
throw new Error('Failed to create session');
|
||
|
|
}
|
||
|
|
return { sessionId: session.id, sessionDirectory: session.directory ?? projectDirectory };
|
||
|
|
})();
|
||
|
|
|
||
|
|
void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined);
|
||
|
|
|
||
|
|
try {
|
||
|
|
useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents);
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
|
||
|
|
args.onSessionCreated?.();
|
||
|
|
useUIStore.getState().closeMainSurfaces();
|
||
|
|
useUIStore.getState().setSessionSwitcherOpen(false);
|
||
|
|
|
||
|
|
postLinearSessionStarted(linear, {
|
||
|
|
sessionId,
|
||
|
|
issueIdentifier: issue.identifier,
|
||
|
|
});
|
||
|
|
|
||
|
|
const configState = useConfigStore.getState();
|
||
|
|
const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
|
||
|
|
const defaultModel = resolveDefaultModelSelection();
|
||
|
|
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
|
||
|
|
const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID;
|
||
|
|
const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined;
|
||
|
|
if (!providerID || !modelID) {
|
||
|
|
toast.error(t('session.linearIssuePicker.error.noModelSelected'));
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const variant = resolveDefaultVariant(providerID, modelID);
|
||
|
|
const visiblePromptText = await renderMagicPrompt('linear.issue.review.visible', {
|
||
|
|
identifier: issue.identifier,
|
||
|
|
});
|
||
|
|
const instructionsText = await renderMagicPrompt('linear.issue.review.instructions');
|
||
|
|
const contextText = buildIssueContextText({ issue, comments });
|
||
|
|
|
||
|
|
void sessionActions.setLinkedIssue(
|
||
|
|
sessionId,
|
||
|
|
sessionDirectory,
|
||
|
|
buildLinkedLinearIssue({
|
||
|
|
identifier: issue.identifier,
|
||
|
|
title: issue.title,
|
||
|
|
url: issue.url,
|
||
|
|
author: login
|
||
|
|
? { login, avatarUrl: issue.assignee?.avatarUrl || undefined }
|
||
|
|
: undefined,
|
||
|
|
linkedAt: Date.now(),
|
||
|
|
}),
|
||
|
|
true,
|
||
|
|
).catch(() => undefined);
|
||
|
|
|
||
|
|
void useSessionUIStore.getState().sendMessage(
|
||
|
|
visiblePromptText,
|
||
|
|
providerID,
|
||
|
|
modelID,
|
||
|
|
agentName,
|
||
|
|
undefined,
|
||
|
|
undefined,
|
||
|
|
[
|
||
|
|
{ text: instructionsText, synthetic: true },
|
||
|
|
{ text: contextText, synthetic: true },
|
||
|
|
],
|
||
|
|
variant,
|
||
|
|
undefined,
|
||
|
|
{ sessionId, directory: sessionDirectory },
|
||
|
|
).catch((error) => {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
toast.error(t('session.linearIssuePicker.toast.sendContextFailed'), {
|
||
|
|
description: message,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
toast.success(t('session.linearIssuePicker.toast.sessionCreated'));
|
||
|
|
return true;
|
||
|
|
} catch (error) {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
toast.error(t('session.linearIssuePicker.toast.startSessionFailed'), { description: message });
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|