Skip to content

Commit da90d53

Browse files
committed
feat: preview session on task input page
1 parent daf1211 commit da90d53

File tree

11 files changed

+369
-75
lines changed

11 files changed

+369
-75
lines changed

apps/twig/src/main/services/agent/service.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -442,13 +442,16 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
442442
const mockNodeDir = this.setupMockNodeEnvironment(taskRunId);
443443
this.setupEnvironment(credentials, mockNodeDir);
444444

445+
// Preview sessions don't persist logs — no real task exists
446+
const isPreview = taskId === "__preview__";
447+
445448
// OTEL log pipeline or legacy S3 writer if FF false
446-
const useOtelPipeline = await this.isFeatureFlagEnabled(
447-
"twig-agent-logs-pipeline",
448-
);
449+
const useOtelPipeline = isPreview
450+
? false
451+
: await this.isFeatureFlagEnabled("twig-agent-logs-pipeline");
449452

450453
log.info("Agent log transport", {
451-
transport: useOtelPipeline ? "otel" : "s3",
454+
transport: isPreview ? "none" : useOtelPipeline ? "otel" : "s3",
452455
taskId,
453456
taskRunId,
454457
});
@@ -466,6 +469,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
466469
logsPath: "/i/v1/agent-logs",
467470
}
468471
: undefined,
472+
skipLogPersistence: isPreview,
469473
debug: !app.isPackaged,
470474
onLog: onAgentLog,
471475
});

apps/twig/src/renderer/features/message-editor/components/EditorToolbar.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ interface EditorToolbarProps {
1313
onAttachFiles?: (files: File[]) => void;
1414
attachTooltip?: string;
1515
iconSize?: number;
16+
/** Hide model and reasoning selectors (when rendered separately) */
17+
hideSelectors?: boolean;
1618
}
1719

1820
export function EditorToolbar({
@@ -23,6 +25,7 @@ export function EditorToolbar({
2325
onAttachFiles,
2426
attachTooltip = "Attach file",
2527
iconSize = 14,
28+
hideSelectors = false,
2629
}: EditorToolbarProps) {
2730
const fileInputRef = useRef<HTMLInputElement>(null);
2831

@@ -68,8 +71,16 @@ export function EditorToolbar({
6871
<Paperclip size={iconSize} weight="bold" />
6972
</IconButton>
7073
</Tooltip>
71-
<ModelSelector taskId={taskId} adapter={adapter} disabled={disabled} />
72-
<ReasoningLevelSelector taskId={taskId} disabled={disabled} />
74+
{!hideSelectors && (
75+
<>
76+
<ModelSelector
77+
taskId={taskId}
78+
adapter={adapter}
79+
disabled={disabled}
80+
/>
81+
<ReasoningLevelSelector taskId={taskId} disabled={disabled} />
82+
</>
83+
)}
7384
</Flex>
7485
);
7586
}

apps/twig/src/renderer/features/message-editor/components/ModeIndicatorInput.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ const DEFAULT_STYLE: ModeStyle = {
5555
};
5656

5757
interface ModeIndicatorInputProps {
58-
modeOption: SessionConfigOption;
58+
modeOption: SessionConfigOption | undefined;
59+
onCycleMode?: () => void;
5960
}
6061

6162
function flattenOptions(
@@ -70,7 +71,12 @@ function flattenOptions(
7071
return options as SessionConfigSelectOption[];
7172
}
7273

73-
export function ModeIndicatorInput({ modeOption }: ModeIndicatorInputProps) {
74+
export function ModeIndicatorInput({
75+
modeOption,
76+
onCycleMode,
77+
}: ModeIndicatorInputProps) {
78+
if (!modeOption) return null;
79+
7480
const id = modeOption.currentValue;
7581

7682
const style = MODE_STYLES[id] ?? DEFAULT_STYLE;
@@ -80,7 +86,13 @@ export function ModeIndicatorInput({ modeOption }: ModeIndicatorInputProps) {
8086
const label = option?.name ?? id;
8187

8288
return (
83-
<Flex align="center" justify="between" py="1">
89+
<Flex
90+
align="center"
91+
justify="between"
92+
py="1"
93+
style={onCycleMode ? { cursor: "pointer" } : undefined}
94+
onClick={onCycleMode}
95+
>
8496
<Flex align="center" gap="1">
8597
<Text
8698
size="1"

apps/twig/src/renderer/features/sessions/service/service.ts

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ import { ANALYTICS_EVENTS } from "@/types/analytics";
4646

4747
const log = logger.scope("session-service");
4848

49+
export const PREVIEW_TASK_ID = "__preview__";
50+
4951
interface AuthCredentials {
5052
apiKey: string;
5153
apiHost: string;
@@ -60,6 +62,7 @@ interface ConnectParams {
6062
executionMode?: ExecutionMode;
6163
adapter?: "claude" | "codex";
6264
model?: string;
65+
reasoningLevel?: string;
6366
}
6467

6568
// --- Singleton Service Instance ---
@@ -95,6 +98,8 @@ export class SessionService {
9598
permission?: { unsubscribe: () => void };
9699
}
97100
>();
101+
/** Version counter to discard stale preview session results */
102+
private previewVersion = 0;
98103

99104
/**
100105
* Connect to a task session.
@@ -136,8 +141,15 @@ export class SessionService {
136141
}
137142

138143
private async doConnect(params: ConnectParams): Promise<void> {
139-
const { task, repoPath, initialPrompt, executionMode, adapter, model } =
140-
params;
144+
const {
145+
task,
146+
repoPath,
147+
initialPrompt,
148+
executionMode,
149+
adapter,
150+
model,
151+
reasoningLevel,
152+
} = params;
141153
const { id: taskId, latest_run: latestRun } = task;
142154
const taskTitle = task.title || task.description || "Task";
143155

@@ -214,6 +226,7 @@ export class SessionService {
214226
executionMode,
215227
adapter,
216228
model,
229+
reasoningLevel,
217230
);
218231
}
219232
} catch (error) {
@@ -366,6 +379,7 @@ export class SessionService {
366379
executionMode?: ExecutionMode,
367380
adapter?: "claude" | "codex",
368381
model?: string,
382+
reasoningLevel?: string,
369383
): Promise<void> {
370384
if (!auth.client) {
371385
throw new Error("Unable to reach server. Please check your connection.");
@@ -425,6 +439,15 @@ export class SessionService {
425439
);
426440
}
427441

442+
// Set reasoning level if provided (e.g., from Codex adapter's preview session)
443+
if (reasoningLevel) {
444+
await this.setSessionConfigOptionByCategory(
445+
taskId,
446+
"thought_level",
447+
reasoningLevel,
448+
);
449+
}
450+
428451
if (initialPrompt?.length) {
429452
await this.sendPrompt(taskId, initialPrompt);
430453
}
@@ -448,6 +471,127 @@ export class SessionService {
448471
sessionStoreSetters.removeSession(session.taskRunId);
449472
}
450473

474+
// --- Preview Session Management ---
475+
476+
/**
477+
* Start a lightweight preview session for the task input page.
478+
* This session is used solely to retrieve adapter-specific config options
479+
* (models, modes, reasoning levels) without creating a real PostHog task.
480+
*
481+
* Uses a version counter to prevent race conditions when rapidly switching
482+
* adapters — stale results from a previous start are discarded.
483+
*/
484+
async startPreviewSession(params: {
485+
adapter: "claude" | "codex";
486+
repoPath?: string;
487+
}): Promise<void> {
488+
// Increment version to invalidate any in-flight start
489+
const version = ++this.previewVersion;
490+
491+
// Cancel any existing preview session first
492+
await this.cancelPreviewSession();
493+
494+
// Check if a newer start was requested while we were cancelling
495+
if (version !== this.previewVersion) {
496+
log.info("Preview session start superseded, skipping", { version });
497+
return;
498+
}
499+
500+
const auth = this.getAuthCredentials();
501+
if (!auth) {
502+
log.info("Skipping preview session - not authenticated");
503+
return;
504+
}
505+
506+
const taskRunId = `preview-${crypto.randomUUID()}`;
507+
const session = this.createBaseSession(
508+
taskRunId,
509+
PREVIEW_TASK_ID,
510+
"Preview",
511+
);
512+
session.adapter = params.adapter;
513+
sessionStoreSetters.setSession(session);
514+
515+
try {
516+
const result = await trpcVanilla.agent.start.mutate({
517+
taskId: PREVIEW_TASK_ID,
518+
taskRunId,
519+
repoPath: params.repoPath || "~",
520+
apiKey: auth.apiKey,
521+
apiHost: auth.apiHost,
522+
projectId: auth.projectId,
523+
adapter: params.adapter,
524+
});
525+
526+
// Check again after the async start — a newer call may have superseded us
527+
if (version !== this.previewVersion) {
528+
log.info(
529+
"Preview session start superseded after agent.start, cleaning up stale session",
530+
{ taskRunId, version },
531+
);
532+
// Clean up the session we just started but is now stale
533+
trpcVanilla.agent.cancel
534+
.mutate({ sessionId: taskRunId })
535+
.catch((err) => {
536+
log.warn("Failed to cancel stale preview session", {
537+
taskRunId,
538+
error: err,
539+
});
540+
});
541+
sessionStoreSetters.removeSession(taskRunId);
542+
return;
543+
}
544+
545+
const configOptions = result.configOptions as
546+
| SessionConfigOption[]
547+
| undefined;
548+
549+
sessionStoreSetters.updateSession(taskRunId, {
550+
status: "connected",
551+
channel: result.channel,
552+
configOptions,
553+
});
554+
555+
this.subscribeToChannel(taskRunId);
556+
557+
log.info("Preview session started", {
558+
taskRunId,
559+
adapter: params.adapter,
560+
configOptionsCount: configOptions?.length ?? 0,
561+
});
562+
} catch (error) {
563+
// Only clean up if we're still the current version
564+
if (version === this.previewVersion) {
565+
log.error("Failed to start preview session", { error });
566+
sessionStoreSetters.removeSession(taskRunId);
567+
}
568+
}
569+
}
570+
571+
/**
572+
* Cancel and clean up the preview session.
573+
* Unsubscribes and removes from store first (so nothing writes to the old
574+
* session), then awaits the cancel on the main process.
575+
*/
576+
async cancelPreviewSession(): Promise<void> {
577+
const session = sessionStoreSetters.getSessionByTaskId(PREVIEW_TASK_ID);
578+
if (!session) return;
579+
580+
const { taskRunId } = session;
581+
582+
// Unsubscribe and remove from store first so nothing writes to the old session
583+
this.unsubscribeFromChannel(taskRunId);
584+
sessionStoreSetters.removeSession(taskRunId);
585+
586+
try {
587+
await trpcVanilla.agent.cancel.mutate({ sessionId: taskRunId });
588+
} catch (error) {
589+
log.warn("Failed to cancel preview session", { taskRunId, error });
590+
}
591+
592+
log.info("Preview session cancelled", { taskRunId });
593+
}
594+
451595
// --- Subscription Management ---
452596

453597
private subscribeToChannel(taskRunId: string): void {
@@ -517,6 +661,7 @@ export class SessionService {
517661
}
518662

519663
this.connectingTasks.clear();
664+
this.previewVersion = 0;
520665
}
521666

522667
private handleSessionEvent(taskRunId: string, acpMsg: AcpMessage): void {

0 commit comments

Comments
 (0)