Skip to content

Commit ac85e45

Browse files
authored
feat: Create a preview session on task input page (#863)
### TL;DR Added a preview session feature for the task input page that loads adapter-specific configuration options without creating a real task. ### What changed? - Created a preview session system that loads model options, reasoning levels, and execution modes for the selected adapter - Added a visual mode indicator that shows the current execution mode with an icon and label - Separated model and reasoning level selectors from the editor toolbar for better organization - Disabled log persistence for preview sessions since they don't correspond to real tasks - Added keyboard shortcut (Shift+Tab) to cycle through execution modes - Implemented version tracking to prevent race conditions when rapidly switching adapters ### How to test? 1. Open the task input page and verify that model options and reasoning levels load correctly 2. Change the adapter and confirm that the available options update accordingly 3. Use Shift+Tab to cycle through execution modes and verify the mode indicator updates 4. Check that no logs are persisted for preview sessions 5. Rapidly switch between adapters to ensure no race conditions occur ### Why make this change? The task input page needed to display adapter-specific configuration options before task creation, but previously these options were only available after creating a task. This change improves the user experience by showing relevant options upfront, allowing users to configure their task properly before submission. It also prevents unnecessary log persistence for these temporary preview sessions.
1 parent daf1211 commit ac85e45

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)