Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 19 additions & 24 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,30 +95,25 @@ describe("buildHiddenProviderInput", () => {
).toBeUndefined();
});

it("wraps the prompt enhancement as hidden interpretation guidance", () => {
const providerInput = buildHiddenProviderInput({
prompt: "Fix this \uFFFC button",
terminalContexts: [
{
id: "ctx-1",
threadId: ThreadId.makeUnsafe("thread-1"),
terminalId: "default",
terminalLabel: "Terminal 1",
lineStart: 4,
lineEnd: 6,
text: "bun lint\nerror: failed",
createdAt: "2026-03-17T12:52:29.000Z",
},
],
promptEnhancement: "specificity",
});

expect(providerInput).toContain(
'Before responding, improve the user\'s request using the "Add specificity" enhancement mode',
);
expect(providerInput).toContain("Fix this @terminal-1:4-6 button");
expect(providerInput).toContain("<terminal_context>");
expect(providerInput).toContain("bun lint");
it("does not add hidden provider guidance for visible prompt enhancements", () => {
expect(
buildHiddenProviderInput({
prompt: "Fix this \uFFFC button",
terminalContexts: [
{
id: "ctx-1",
threadId: ThreadId.makeUnsafe("thread-1"),
terminalId: "default",
terminalLabel: "Terminal 1",
lineStart: 4,
lineEnd: 6,
text: "bun lint\nerror: failed",
createdAt: "2026-03-17T12:52:29.000Z",
},
],
promptEnhancement: "specificity",
}),
).toBeUndefined();
});
});

Expand Down
27 changes: 5 additions & 22 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@ import { randomUUID } from "~/lib/utils";
import { type ComposerAttachment, type DraftThreadState } from "../composerDraftStore";
import { Schema } from "effect";
import {
buildTerminalContextBlock,
filterTerminalContextsWithText,
materializeInlineTerminalContextPrompt,
stripInlineTerminalContextPlaceholders,
type TerminalContextDraft,
} from "../lib/terminalContext";
import { buildEnhancedPromptInput, type PromptEnhancementId } from "../promptEnhancement";
import { type PromptEnhancementId } from "../promptEnhancement";
import { normalizeThreadTitle } from "../threadTitle";

export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "okcode:last-invoked-script-by-project";
Expand Down Expand Up @@ -169,25 +167,10 @@ export function buildHiddenProviderInput(options: {
terminalContexts: ReadonlyArray<TerminalContextDraft>;
promptEnhancement: PromptEnhancementId | null | undefined;
}): string | undefined {
if (!options.promptEnhancement) {
return undefined;
}

const materializedPrompt = materializeInlineTerminalContextPrompt(
options.prompt,
options.terminalContexts,
).trim();
const enhancedPrompt = buildEnhancedPromptInput(materializedPrompt, options.promptEnhancement);
if (enhancedPrompt.length === 0) {
return undefined;
}

const contextBlock = buildTerminalContextBlock(options.terminalContexts);
if (contextBlock.length === 0) {
return enhancedPrompt;
}

return `${enhancedPrompt}\n\n${contextBlock}`;
void options.prompt;
void options.terminalContexts;
void options.promptEnhancement;
return undefined;
}

export function buildExpiredTerminalContextToastCopy(
Expand Down
96 changes: 77 additions & 19 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ import { usePreviewStateStore } from "~/previewStateStore";
import { useClientMode } from "~/hooks/useClientMode";
import { useTransportState } from "~/hooks/useTransportState";
import { hasCustomThreadTitle, normalizeThreadTitle } from "~/threadTitle";
import { type PromptEnhancementId } from "../promptEnhancement";
import { enhancePrompt, type PromptEnhancementId } from "../promptEnhancement";

const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
Expand Down Expand Up @@ -416,6 +416,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const composerDraft = useComposerThreadDraft(threadId);
const prompt = composerDraft.prompt;
const composerPromptEnhancement = composerDraft.promptEnhancement;
const composerPromptEnhancementOriginalPrompt = composerDraft.promptEnhancementOriginalPrompt;
const composerAttachments = composerDraft.attachments;
const composerImageAttachments = useMemo(
() =>
Expand Down Expand Up @@ -445,8 +446,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
);
const nonPersistedComposerAttachmentIds = composerDraft.nonPersistedAttachmentIds;
const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
const setComposerDraftPromptEnhancement = useComposerDraftStore(
(store) => store.setPromptEnhancement,
const setComposerDraftPromptEnhancementState = useComposerDraftStore(
(store) => store.setPromptEnhancementState,
);
const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider);
const setComposerDraftModel = useComposerDraftStore((store) => store.setModel);
Expand Down Expand Up @@ -508,6 +509,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const [sendStartedAt, setSendStartedAt] = useState<string | null>(null);
const [isConnecting, _setIsConnecting] = useState(false);
const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false);
const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
ApprovalRequestId[]
Expand Down Expand Up @@ -602,11 +604,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
},
[setComposerDraftPrompt, threadId],
);
const setPromptEnhancement = useCallback(
(nextPromptEnhancement: PromptEnhancementId | null) => {
setComposerDraftPromptEnhancement(threadId, nextPromptEnhancement);
const setPromptEnhancementState = useCallback(
(
nextPromptEnhancement: PromptEnhancementId | null,
originalPrompt: string | null | undefined,
) => {
setComposerDraftPromptEnhancementState(threadId, {
promptEnhancement: nextPromptEnhancement,
originalPrompt,
});
},
[setComposerDraftPromptEnhancement, threadId],
[setComposerDraftPromptEnhancementState, threadId],
);
const addComposerAttachment = useCallback(
(attachment: ComposerAttachment) => {
Expand Down Expand Up @@ -3011,6 +3019,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
: null;
const nextPrompt = latestDraft?.prompt ?? promptRef.current;
const nextPromptEnhancement = latestDraft?.promptEnhancement ?? composerPromptEnhancement;
const nextPromptEnhancementOriginalPrompt =
latestDraft?.promptEnhancementOriginalPrompt ?? composerPromptEnhancementOriginalPrompt;
const nextAttachments = latestDraft?.attachments ?? composerAttachmentsRef.current;
const nextTerminalContexts =
latestDraft?.terminalContexts ?? composerTerminalContextsRef.current;
Expand All @@ -3020,10 +3030,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
return {
prompt: nextPrompt,
promptEnhancement: nextPromptEnhancement,
promptEnhancementOriginalPrompt: nextPromptEnhancementOriginalPrompt,
attachments: nextAttachments,
terminalContexts: nextTerminalContexts,
};
}, [activeThread, composerPromptEnhancement]);
}, [activeThread, composerPromptEnhancement, composerPromptEnhancementOriginalPrompt]);

const onSend = async (e?: { preventDefault: () => void }) => {
e?.preventDefault();
Expand All @@ -3036,6 +3047,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
const liveComposerDraft = readLiveComposerDraftSnapshot();
const promptForSend = liveComposerDraft.prompt;
const promptEnhancementForSend = liveComposerDraft.promptEnhancement;
const promptEnhancementOriginalPromptForSend =
liveComposerDraft.promptEnhancementOriginalPrompt;
const composerAttachmentsForSend = liveComposerDraft.attachments;
const composerTerminalContextsForSend = liveComposerDraft.terminalContexts;
const {
Expand All @@ -3061,7 +3074,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
await onSubmitPlanFollowUp({
text: followUp.text,
interactionMode: followUp.interactionMode,
promptEnhancement: promptEnhancementForSend,
});
return;
}
Expand Down Expand Up @@ -3610,7 +3622,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
});
promptRef.current = promptForSend;
setPrompt(promptForSend);
setPromptEnhancement(promptEnhancementForSend ?? null);
setPromptEnhancementState(
promptEnhancementForSend ?? null,
promptEnhancementOriginalPromptForSend,
);
setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length));
addComposerAttachmentsToDraft(
composerAttachmentsSnapshot.map(cloneComposerAttachmentForRetry),
Expand Down Expand Up @@ -3825,11 +3840,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
async ({
text,
interactionMode: nextInteractionMode,
promptEnhancement,
}: {
text: string;
interactionMode: ProviderInteractionMode;
promptEnhancement: PromptEnhancementId | null | undefined;
}) => {
const api = readNativeApi();
if (
Expand All @@ -3852,11 +3865,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
const threadIdForSend = activeThread.id;
const messageIdForSend = newMessageId();
const messageCreatedAt = new Date().toISOString();
const hiddenProviderInput = buildHiddenProviderInput({
prompt: trimmed,
terminalContexts: [],
promptEnhancement,
});
const outgoingMessageText = formatOutgoingPrompt({
provider: selectedProvider,
effort: selectedPromptEffort,
Expand Down Expand Up @@ -3902,7 +3910,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
text: outgoingMessageText,
attachments: [],
},
...(hiddenProviderInput ? { providerInput: hiddenProviderInput } : {}),
provider: selectedProvider,
model: selectedModel || undefined,
...(selectedModelOptionsForDispatch
Expand Down Expand Up @@ -4133,6 +4140,56 @@ export default function ChatView({ threadId }: ChatViewProps) {
},
[scheduleComposerFocus, setPrompt],
);
const onPromptEnhancementChange = useCallback(
async (nextPromptEnhancement: PromptEnhancementId | null) => {
if (isEnhancingPrompt) {
return;
}

const currentPrompt = promptRef.current;
const currentEnhancement = composerPromptEnhancement;
const revertPrompt = composerPromptEnhancementOriginalPrompt ?? currentPrompt;
const basePrompt = currentEnhancement !== null ? revertPrompt : currentPrompt;

if (nextPromptEnhancement === null) {
promptRef.current = revertPrompt;
setPrompt(revertPrompt);
setPromptEnhancementState(null, null);
const nextCursor = collapseExpandedComposerCursor(revertPrompt, revertPrompt.length);
setComposerCursor(nextCursor);
setComposerTrigger(detectComposerTrigger(revertPrompt, revertPrompt.length));
scheduleComposerFocus();
return;
}

if (basePrompt.trim().length === 0) {
return;
}

setIsEnhancingPrompt(true);
try {
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
const enhancedPrompt = enhancePrompt(basePrompt, nextPromptEnhancement);
promptRef.current = enhancedPrompt;
setPrompt(enhancedPrompt);
setPromptEnhancementState(nextPromptEnhancement, basePrompt);
const nextCursor = collapseExpandedComposerCursor(enhancedPrompt, enhancedPrompt.length);
setComposerCursor(nextCursor);
setComposerTrigger(detectComposerTrigger(enhancedPrompt, enhancedPrompt.length));
scheduleComposerFocus();
} finally {
setIsEnhancingPrompt(false);
}
},
[
composerPromptEnhancement,
composerPromptEnhancementOriginalPrompt,
isEnhancingPrompt,
scheduleComposerFocus,
setPrompt,
setPromptEnhancementState,
],
);
const providerTraitsMenuContent = renderProviderTraitsMenuContent({
provider: selectedProvider,
threadId,
Expand Down Expand Up @@ -5252,7 +5309,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
<PromptEnhancer
prompt={prompt}
value={composerPromptEnhancement}
onChange={setPromptEnhancement}
onChange={onPromptEnhancementChange}
isEnhancing={isEnhancingPrompt}
/>
<Button
variant="ghost"
Expand Down
46 changes: 32 additions & 14 deletions apps/web/src/components/PromptEnhancer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CheckIcon, SparklesIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { Button } from "./ui/button";
import {
Menu,
Expand All @@ -18,14 +19,21 @@ import {
interface PromptEnhancerProps {
prompt: string;
value: PromptEnhancementId | null;
onChange: (nextValue: PromptEnhancementId | null) => void;
onChange: (nextValue: PromptEnhancementId | null) => void | Promise<void>;
isEnhancing?: boolean;
disabled?: boolean;
}

export default function PromptEnhancer({ prompt, value, onChange, disabled }: PromptEnhancerProps) {
export default function PromptEnhancer({
prompt,
value,
onChange,
isEnhancing = false,
disabled,
}: PromptEnhancerProps) {
const hasPrompt = prompt.trim().length > 0;
const activeEnhancement = getPromptEnhancementById(value);
const canOpenMenu = !disabled && (hasPrompt || value !== null);
const canOpenMenu = !disabled && !isEnhancing && (hasPrompt || value !== null);

return (
<Menu>
Expand All @@ -35,26 +43,31 @@ export default function PromptEnhancer({ prompt, value, onChange, disabled }: Pr
variant="ghost"
size="icon-xs"
type="button"
className={
className={cn(
isEnhancing && "text-foreground",
activeEnhancement
? "text-foreground hover:text-foreground"
: "text-muted-foreground/70 hover:text-foreground/80"
}
: "text-muted-foreground/70 hover:text-foreground/80",
)}
disabled={!canOpenMenu}
title={
activeEnhancement
? `Prompt enhancement: ${activeEnhancement.label}`
: "Enhance prompt"
isEnhancing
? "Enhancing prompt"
: activeEnhancement
? `Prompt enhancement: ${activeEnhancement.label}`
: "Enhance prompt"
}
aria-label={
activeEnhancement
? `Prompt enhancement: ${activeEnhancement.label}`
: "Enhance prompt"
isEnhancing
? "Enhancing prompt"
: activeEnhancement
? `Prompt enhancement: ${activeEnhancement.label}`
: "Enhance prompt"
}
/>
}
>
<SparklesIcon className="size-4" />
<SparklesIcon className={cn("size-4", isEnhancing && "animate-spin")} />
</MenuTrigger>
<MenuPopup align="end" side="top">
<MenuGroup>
Expand All @@ -65,6 +78,7 @@ export default function PromptEnhancer({ prompt, value, onChange, disabled }: Pr
return (
<MenuItem
key={enhancement.id}
disabled={isEnhancing}
onClick={() => onChange(isSelected ? null : enhancement.id)}
>
<div className="flex items-center gap-2">
Expand All @@ -75,7 +89,11 @@ export default function PromptEnhancer({ prompt, value, onChange, disabled }: Pr
)}
<div className="flex flex-col">
<span>{enhancement.label}</span>
<span className="text-muted-foreground text-xs">{enhancement.description}</span>
<span className="text-muted-foreground text-xs">
{isSelected
? `${enhancement.description} • Select again to revert`
: enhancement.description}
</span>
</div>
</div>
</MenuItem>
Expand Down
Loading
Loading