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
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"dev": "bun run src/index.ts",
"build": "node scripts/cli.ts build",
"start": "node dist/index.mjs",
"prepare": "node -e \"process.exit(process.env.CI ? 0 : 1)\" || node ../../scripts/patch-effect-language-service.ts",
"prepare": "node -e \"process.exit(process.env.CI ? 0 : 1)\" || (node ../../scripts/patch-effect-language-service.ts && node ../../scripts/patch-effect-smol-peer-installs.mjs)",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
Expand Down
10 changes: 2 additions & 8 deletions apps/server/src/attachmentText.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import {
PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
type ChatFileAttachment,
} from "@okcode/contracts";
import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS, type ChatFileAttachment } from "@okcode/contracts";

const MAX_FILE_CONTEXT_TOTAL_CHARS = 80_000;
const MAX_FILE_CONTEXT_CHARS_PER_FILE = 24_000;
Expand Down Expand Up @@ -140,10 +137,7 @@ export function buildFileAttachmentContextText(input: {
return input.baseText;
}

const maxChars = Math.max(
1,
Math.floor(input.maxChars ?? PROVIDER_SEND_TURN_MAX_INPUT_CHARS),
);
const maxChars = Math.max(1, Math.floor(input.maxChars ?? PROVIDER_SEND_TURN_MAX_INPUT_CHARS));
let result = input.baseText;
let usedFileContextChars = 0;
let omittedCount = 0;
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,11 @@ async function readFirstPromptText(
if (next.done) {
return undefined;
}
const content = next.value.message.content[0];
const contentBlocks = next.value.message.content;
if (typeof contentBlocks === "string") {
return contentBlocks;
}
const content = contentBlocks[0];
if (!content || content.type !== "text") {
return undefined;
}
Expand Down
31 changes: 20 additions & 11 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type SettingSource,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type { ContentBlockParam, MessageParam } from "@anthropic-ai/sdk/resources";
import {
ApprovalRequestId,
type CanonicalItemType,
Expand Down Expand Up @@ -510,6 +511,11 @@ const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = new Set([
"image/png",
"image/webp",
]);
type ClaudeImageMimeType = "image/gif" | "image/jpeg" | "image/png" | "image/webp";

function isClaudeImageMimeType(value: string): value is ClaudeImageMimeType {
return SUPPORTED_CLAUDE_IMAGE_MIME_TYPES.has(value as ClaudeImageMimeType);
}
const CLAUDE_SETTING_SOURCES = [
"user",
"project",
Expand All @@ -532,23 +538,24 @@ function buildPromptText(input: ProviderSendTurnInput): string {
}

function buildUserMessage(input: {
readonly sdkContent: Array<Record<string, unknown>>;
readonly sdkContent: Array<ContentBlockParam>;
}): SDKUserMessage {
const message: MessageParam = {
role: "user",
content: input.sdkContent,
};
return {
type: "user",
session_id: "",
parent_tool_use_id: null,
message: {
role: "user",
content: input.sdkContent,
},
} as SDKUserMessage;
message,
};
}

function buildClaudeImageContentBlock(input: {
readonly mimeType: string;
readonly mimeType: ClaudeImageMimeType;
readonly bytes: Uint8Array;
}): Record<string, unknown> {
}): ContentBlockParam {
return {
type: "image",
source: {
Expand All @@ -567,7 +574,9 @@ function buildUserMessageEffect(
},
): Effect.Effect<SDKUserMessage, ProviderAdapterRequestError> {
return Effect.gen(function* () {
const imageAttachments: Array<Extract<NonNullable<ProviderSendTurnInput["attachments"]>[number], { type: "image" }>> = [];
const imageAttachments: Array<
Extract<NonNullable<ProviderSendTurnInput["attachments"]>[number], { type: "image" }>
> = [];
const fileAttachments: Array<{
readonly attachment: Extract<
NonNullable<ProviderSendTurnInput["attachments"]>[number],
Expand Down Expand Up @@ -626,14 +635,14 @@ function buildUserMessageEffect(
baseText: buildPromptText(input),
attachments: fileAttachments,
});
const sdkContent: Array<Record<string, unknown>> = [];
const sdkContent: Array<ContentBlockParam> = [];

if (text.length > 0) {
sdkContent.push({ type: "text", text });
}

for (const attachment of imageAttachments) {
if (!SUPPORTED_CLAUDE_IMAGE_MIME_TYPES.has(attachment.mimeType)) {
if (!isClaudeImageMimeType(attachment.mimeType)) {
return yield* new ProviderAdapterRequestError({
provider: PROVIDER,
method: "turn/start",
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1472,7 +1472,9 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
};
}),
{ concurrency: 1 },
).pipe(Effect.map((attachments) => attachments.filter((attachment) => attachment !== null)));
).pipe(
Effect.map((attachments) => attachments.filter((attachment) => attachment !== null)),
);

const turnInputText = buildFileAttachmentContextText({
baseText: input.input?.trim() ?? "",
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
[THREAD_ID]: {
projectId: PROJECT_ID,
createdAt: NOW_ISO,
title: "New thread",
runtimeMode: "full-access",
interactionMode: "chat",
branch: null,
Expand Down Expand Up @@ -1051,6 +1052,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
[THREAD_ID]: {
projectId: PROJECT_ID,
createdAt: NOW_ISO,
title: "New thread",
runtimeMode: "full-access",
interactionMode: "chat",
branch: null,
Expand Down Expand Up @@ -1127,6 +1129,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
[THREAD_ID]: {
projectId: PROJECT_ID,
createdAt: NOW_ISO,
title: "New thread",
runtimeMode: "full-access",
interactionMode: "chat",
branch: "feature/draft",
Expand Down
45 changes: 44 additions & 1 deletion apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { ThreadId } from "@okcode/contracts";
import { ProjectId, ThreadId } from "@okcode/contracts";
import { describe, expect, it } from "vitest";

import {
buildAutoSelectedWorktreeBaseBranchToastCopy,
buildLocalDraftThread,
buildExpiredTerminalContextToastCopy,
deriveComposerSendState,
} from "./ChatView.logic";
Expand Down Expand Up @@ -96,3 +97,45 @@ describe("buildAutoSelectedWorktreeBaseBranchToastCopy", () => {
});
});
});

describe("buildLocalDraftThread", () => {
it("uses a persisted draft title when present", () => {
const thread = buildLocalDraftThread(
ThreadId.makeUnsafe("thread-draft"),
{
projectId: ProjectId.makeUnsafe("project-1"),
createdAt: "2026-03-17T12:52:29.000Z",
title: "Investigate flaky CI",
runtimeMode: "full-access",
interactionMode: "chat",
branch: null,
worktreePath: null,
envMode: "local",
},
"gpt-5.4",
null,
);

expect(thread.title).toBe("Investigate flaky CI");
});

it("falls back to the default title when the draft title is empty", () => {
const thread = buildLocalDraftThread(
ThreadId.makeUnsafe("thread-draft-empty"),
{
projectId: ProjectId.makeUnsafe("project-1"),
createdAt: "2026-03-17T12:52:29.000Z",
title: " ",
runtimeMode: "full-access",
interactionMode: "chat",
branch: null,
worktreePath: null,
envMode: "local",
},
"gpt-5.4",
null,
);

expect(thread.title).toBe("New thread");
});
});
3 changes: 2 additions & 1 deletion apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
stripInlineTerminalContextPlaceholders,
type TerminalContextDraft,
} from "../lib/terminalContext";
import { normalizeThreadTitle } from "../threadTitle";

export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "okcode:last-invoked-script-by-project";
const WORKTREE_BRANCH_PREFIX = "okcode";
Expand All @@ -24,7 +25,7 @@ export function buildLocalDraftThread(
id: threadId,
codexThreadId: null,
projectId: draftThread.projectId,
title: "New thread",
title: normalizeThreadTitle(draftThread.title),
model: fallbackModel,
runtimeMode: draftThread.runtimeMode,
interactionMode: draftThread.interactionMode,
Expand Down
42 changes: 29 additions & 13 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ import { readDesktopPreviewBridge } from "~/desktopPreview";
import { usePreviewStateStore } from "~/previewStateStore";
import { useClientMode } from "~/hooks/useClientMode";
import { useTransportState } from "~/hooks/useTransportState";
import { hasCustomThreadTitle, normalizeThreadTitle } from "~/threadTitle";

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 @@ -436,6 +437,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
);
const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const setDraftThreadTitle = useComposerDraftStore((store) => store.setDraftThreadTitle);
const getDraftThreadByProjectId = useComposerDraftStore(
(store) => store.getDraftThreadByProjectId,
);
Expand Down Expand Up @@ -2374,7 +2376,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
// Stage attachments in persisted draft state first so persist middleware can write them.
syncComposerDraftPersistedAttachments(threadId, serialized);
} catch {
const currentAttachmentIds = new Set(composerAttachments.map((attachment) => attachment.id));
const currentAttachmentIds = new Set(
composerAttachments.map((attachment) => attachment.id),
);
const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
const fallbackPersistedIds = fallbackPersistedAttachments
.map((attachment) => attachment.id)
Expand Down Expand Up @@ -2523,7 +2527,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
nextQueued.text,
composerTerminalContextsSnapshot,
);
const fallbackOutgoingText = nextQueued.attachments.some((attachment) => attachment.type === "image")
const fallbackOutgoingText = nextQueued.attachments.some(
(attachment) => attachment.type === "image",
)
? IMAGE_ONLY_BOOTSTRAP_PROMPT
: "";
const outgoingMessageText = formatOutgoingPrompt({
Expand Down Expand Up @@ -3191,18 +3197,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
}
}

const firstComposerAttachment = composerAttachmentsSnapshot[0] ?? null;
let titleSeed = trimmed;
if (!titleSeed) {
if (firstComposerAttachment) {
titleSeed = `${firstComposerAttachment.type === "image" ? "Image" : "File"}: ${firstComposerAttachment.name}`;
} else if (composerTerminalContextsSnapshot.length > 0) {
titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!);
} else {
titleSeed = "New thread";
const manualThreadTitle = hasCustomThreadTitle(activeThread.title)
? normalizeThreadTitle(activeThread.title)
: null;
let title = manualThreadTitle;
if (!title) {
const firstComposerAttachment = composerAttachmentsSnapshot[0] ?? null;
let titleSeed = trimmed;
if (!titleSeed) {
if (firstComposerAttachment) {
titleSeed = `${firstComposerAttachment.type === "image" ? "Image" : "File"}: ${firstComposerAttachment.name}`;
} else if (composerTerminalContextsSnapshot.length > 0) {
titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!);
} else {
titleSeed = normalizeThreadTitle(null);
}
}
title = truncateTitle(titleSeed);
}
const title = truncateTitle(titleSeed);
let threadCreateModel: ModelSlug =
selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex;

Expand Down Expand Up @@ -3249,7 +3261,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}

// Auto-title from first message
if (isFirstMessage && isServerThread) {
if (isFirstMessage && isServerThread && !hasCustomThreadTitle(activeThread.title)) {
await api.orchestration.dispatchCommand({
type: "thread.meta.update",
commandId: newCommandId(),
Expand Down Expand Up @@ -4327,6 +4339,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeProjectName={activeProject?.name}
activeProjectCwd={activeProject?.cwd}
isGitRepo={isGitRepo}
isLocalDraftThread={isLocalDraftThread}
openInCwd={gitCwd}
activeProjectScripts={activeProject?.scripts}
preferredScriptId={
Expand All @@ -4343,6 +4356,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
gitCwd={gitCwd}
diffOpen={diffOpen}
clientMode={clientMode}
onRenameDraftThreadTitle={(title) => {
setDraftThreadTitle(activeThread.id, title);
}}
onRunProjectScript={(script) => {
void runProjectScript(script);
}}
Expand Down
Loading
Loading