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
4 changes: 2 additions & 2 deletions apps/desktop/BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Run the dev server without env validation or auth:
SKIP_ENV_VALIDATION=1 bun run dev
```

This skips environment variable validation and the sign-in screen, useful for local development without credentials.
This skips environment variable validation and the sign-in screen. Desktop chat also falls back to local-only session bootstrap in this mode, so you can test chat/streaming without the cloud API as long as you have local model credentials configured.

# Release

Expand Down Expand Up @@ -36,4 +36,4 @@ ls -la release/*.AppImage
ls -la release/*-linux.yml
```

If both files exist, packaging produced the Linux artifact + updater metadata that `electron-updater` expects.
If both files exist, packaging produced the Linux artifact + updater metadata that `electron-updater` expects.
43 changes: 43 additions & 0 deletions apps/desktop/src/renderer/lib/dev-chat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "bun:test";
import {
DEV_CHAT_MODELS,
getDesktopChatModelOptions,
isDesktopChatSessionReady,
resolveDesktopChatOrganizationId,
} from "./dev-chat";

describe("dev chat helpers", () => {
it("uses the mock organization in dev mode", () => {
expect(resolveDesktopChatOrganizationId(null, true)).toBe("mock-org-id");
expect(resolveDesktopChatOrganizationId("org-123", true)).toBe(
"mock-org-id",
);
});

it("keeps the real organization outside dev mode", () => {
expect(resolveDesktopChatOrganizationId("org-123", false)).toBe("org-123");
expect(resolveDesktopChatOrganizationId(null, false)).toBeNull();
});

it("treats local session ids as ready in dev mode", () => {
expect(
isDesktopChatSessionReady({
sessionId: "session-123",
hasPersistedSession: false,
skipEnvValidation: true,
}),
).toBe(true);
expect(
isDesktopChatSessionReady({
sessionId: null,
hasPersistedSession: false,
skipEnvValidation: true,
}),
).toBe(false);
});

it("returns the fallback model list only in dev mode", () => {
expect(getDesktopChatModelOptions(true)).toEqual(DEV_CHAT_MODELS);
expect(getDesktopChatModelOptions(false)).toEqual([]);
});
});
64 changes: 64 additions & 0 deletions apps/desktop/src/renderer/lib/dev-chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { ModelOption } from "renderer/components/Chat/ChatInterface/types";
import { env } from "renderer/env.renderer";
import { MOCK_ORG_ID } from "shared/constants";

export const DEV_CHAT_MODELS: ModelOption[] = [
{
id: "anthropic/claude-opus-4-6",
name: "Opus 4.6",
provider: "Anthropic",
},
{
id: "anthropic/claude-sonnet-4-6",
name: "Sonnet 4.6",
provider: "Anthropic",
},
{
id: "anthropic/claude-haiku-4-5",
name: "Haiku 4.5",
provider: "Anthropic",
},
{
id: "openai/gpt-5.4",
name: "GPT-5.4",
provider: "OpenAI",
},
{
id: "openai/gpt-5.3-codex",
name: "GPT-5.3 Codex",
provider: "OpenAI",
},
];

export function isDesktopChatDevMode(
skipEnvValidation = env.SKIP_ENV_VALIDATION,
): boolean {
return skipEnvValidation;
}

export function resolveDesktopChatOrganizationId(
activeOrganizationId: string | null | undefined,
skipEnvValidation = env.SKIP_ENV_VALIDATION,
): string | null {
if (skipEnvValidation) return MOCK_ORG_ID;
return activeOrganizationId ?? null;
}

export function isDesktopChatSessionReady({
sessionId,
hasPersistedSession,
skipEnvValidation = env.SKIP_ENV_VALIDATION,
}: {
sessionId: string | null;
hasPersistedSession: boolean;
skipEnvValidation?: boolean;
}): boolean {
if (skipEnvValidation) return Boolean(sessionId);
return hasPersistedSession;
}

export function getDesktopChatModelOptions(
skipEnvValidation = env.SKIP_ENV_VALIDATION,
): ModelOption[] {
return skipEnvValidation ? DEV_CHAT_MODELS : [];
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import type {
PermissionMode,
} from "renderer/components/Chat/ChatInterface/types";
import { apiTrpcClient } from "renderer/lib/api-trpc-client";
import {
getDesktopChatModelOptions,
isDesktopChatDevMode,
} from "renderer/lib/dev-chat";
import { posthog } from "renderer/lib/posthog";
import { useChatPreferencesStore } from "renderer/stores/chat-preferences";
import {
Expand Down Expand Up @@ -128,12 +132,14 @@ function useAvailableModels(): {
models: ModelOption[];
defaultModel: ModelOption | null;
} {
const localModels = getDesktopChatModelOptions();
const { data } = useQuery({
queryKey: ["chat", "models"],
queryFn: () => apiTrpcClient.chat.getModels.query(),
enabled: !isDesktopChatDevMode(),
staleTime: Number.POSITIVE_INFINITY,
});
const models = data?.models ?? [];
const models = localModels.length > 0 ? localModels : (data?.models ?? []);
return { models, defaultModel: models[0] ?? null };
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FileUIPart } from "ai";
import { apiTrpcClient } from "renderer/lib/api-trpc-client";
import { isDesktopChatDevMode } from "renderer/lib/dev-chat";

async function getHttpErrorDetail(response: Response): Promise<string> {
const errorBody = await response
Expand Down Expand Up @@ -45,12 +46,22 @@ async function uploadFile(
if (signal?.aborted) {
throw new DOMException("The operation was aborted", "AbortError");
}
const fileData = await blobToDataUrl(blob);

if (isDesktopChatDevMode()) {
return {
type: "file",
url: fileData,
mediaType: file.mediaType,
filename,
};
}

const result = await apiTrpcClient.chat.uploadAttachment.mutate({
sessionId,
filename,
mediaType: file.mediaType,
fileData: await blobToDataUrl(blob),
fileData,
});
return { type: "file", ...result };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { useLiveQuery } from "@tanstack/react-db";
import { useCallback, useMemo } from "react";
import { apiTrpcClient } from "renderer/lib/api-trpc-client";
import { authClient } from "renderer/lib/auth-client";
import {
isDesktopChatDevMode,
resolveDesktopChatOrganizationId,
} from "renderer/lib/dev-chat";
import { posthog } from "renderer/lib/posthog";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";

Expand Down Expand Up @@ -37,13 +41,15 @@ async function createSessionRecord(input: {
sessionId: string;
v2WorkspaceId: string;
}): Promise<void> {
if (isDesktopChatDevMode()) return;
await apiTrpcClient.chat.createSession.mutate({
sessionId: input.sessionId,
v2WorkspaceId: input.v2WorkspaceId,
});
}

async function deleteSessionRecord(sessionId: string): Promise<void> {
if (isDesktopChatDevMode()) return;
const result = await apiTrpcClient.chat.deleteSession.mutate({
sessionId,
});
Expand All @@ -62,7 +68,9 @@ export function useWorkspaceChatController({
workspaceId: string;
}) {
const { data: session } = authClient.useSession();
const organizationId = session?.session?.activeOrganizationId ?? null;
const organizationId = resolveDesktopChatOrganizationId(
session?.session?.activeOrganizationId,
);
const collections = useCollections();

const { data: workspace } = workspaceTrpc.workspace.get.useQuery(
Expand Down Expand Up @@ -140,10 +148,24 @@ export function useWorkspaceChatController({
return nextSessionId;
}, [onSessionIdChange, organizationId, sessionId, sessions, workspaceId]);

const sessionItems = useMemo(
() => sessions.map((item) => toSessionSelectorItem(item)),
[sessions],
);
const sessionItems = useMemo(() => {
const nextItems = sessions.map((item) => toSessionSelectorItem(item));
if (
!isDesktopChatDevMode() ||
!sessionId ||
nextItems.some((item) => item.sessionId === sessionId)
) {
return nextItems;
}
return [
{
sessionId,
title: "",
updatedAt: new Date(),
},
...nextItems,
];
}, [sessionId, sessions]);

return {
sessionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import type {
PermissionMode,
} from "renderer/components/Chat/ChatInterface/types";
import { apiTrpcClient } from "renderer/lib/api-trpc-client";
import {
getDesktopChatModelOptions,
isDesktopChatDevMode,
} from "renderer/lib/dev-chat";
import { posthog } from "renderer/lib/posthog";
import { useChatPreferencesStore } from "renderer/stores/chat-preferences";
import { useTabsStore } from "renderer/stores/tabs/store";
Expand Down Expand Up @@ -128,12 +132,14 @@ function useAvailableModels(): {
models: ModelOption[];
defaultModel: ModelOption | null;
} {
const localModels = getDesktopChatModelOptions();
const { data } = useQuery({
queryKey: ["chat", "models"],
queryFn: () => apiTrpcClient.chat.getModels.query(),
enabled: !isDesktopChatDevMode(),
staleTime: Number.POSITIVE_INFINITY,
});
const models = data?.models ?? [];
const models = localModels.length > 0 ? localModels : (data?.models ?? []);
return { models, defaultModel: models[0] ?? null };
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FileUIPart } from "ai";
import { apiTrpcClient } from "renderer/lib/api-trpc-client";
import { isDesktopChatDevMode } from "renderer/lib/dev-chat";

async function getHttpErrorDetail(response: Response): Promise<string> {
const errorBody = await response
Expand Down Expand Up @@ -45,12 +46,22 @@ async function uploadFile(
if (signal?.aborted) {
throw new DOMException("The operation was aborted", "AbortError");
}
const fileData = await blobToDataUrl(blob);

if (isDesktopChatDevMode()) {
return {
type: "file",
url: fileData,
mediaType: file.mediaType,
filename,
};
}

const result = await apiTrpcClient.chat.uploadAttachment.mutate({
sessionId,
filename,
mediaType: file.mediaType,
fileData: await blobToDataUrl(blob),
fileData,
});
return { type: "file", ...result };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import type { StartFreshSessionResult } from "renderer/components/Chat/ChatInter
import { env } from "renderer/env.renderer";
import { apiTrpcClient } from "renderer/lib/api-trpc-client";
import { authClient, getAuthToken } from "renderer/lib/auth-client";
import {
isDesktopChatDevMode,
isDesktopChatSessionReady,
resolveDesktopChatOrganizationId,
} from "renderer/lib/dev-chat";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { posthog } from "renderer/lib/posthog";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
Expand Down Expand Up @@ -80,6 +85,7 @@ async function createSessionRecord(input: {
organizationId: string;
workspaceId: string;
}): Promise<void> {
if (isDesktopChatDevMode()) return;
const token = getAuthToken();
const response = await fetch(`${apiUrl}/api/chat/${input.sessionId}`, {
method: "PUT",
Expand All @@ -106,6 +112,7 @@ async function createSessionRecord(input: {
}

async function deleteSessionRecord(sessionId: string): Promise<void> {
if (isDesktopChatDevMode()) return;
const token = getAuthToken();
const response = await fetch(`${apiUrl}/api/chat/${sessionId}/stream`, {
method: "DELETE",
Expand All @@ -131,7 +138,9 @@ export function useChatPaneController({
const launchConfig = pane?.chat?.launchConfig ?? null;
const needsLegacySessionBootstrap = sessionId === null;
const { data: session } = authClient.useSession();
const organizationId = session?.session?.activeOrganizationId ?? null;
const organizationId = resolveDesktopChatOrganizationId(
session?.session?.activeOrganizationId,
);
const collections = useCollections();
const legacySessionBootstrapRef = useRef(false);
const ensuredRef = useRef<string | null>(null);
Expand All @@ -154,6 +163,7 @@ export function useChatPaneController({
);

useEffect(() => {
if (isDesktopChatDevMode()) return;
if (existsRemotely) return;
if (!workspace?.project || !organizationId) return;
if (ensuredRef.current === workspaceId) return;
Expand Down Expand Up @@ -213,9 +223,12 @@ export function useChatPaneController({
);
return scopedOrUnscoped.length > 0 ? scopedOrUnscoped : allSessions;
}, [allSessions, workspaceId]);
const hasCurrentSessionRecord = Boolean(
sessionId && sessions.some((item) => item.id === sessionId),
);
const hasCurrentSessionRecord = isDesktopChatSessionReady({
sessionId,
hasPersistedSession: Boolean(
sessionId && sessions.some((item) => item.id === sessionId),
),
});
const [isSessionInitializing, setIsSessionInitializing] = useState(false);
const hasCurrentSessionRecordRef = useRef(hasCurrentSessionRecord);
const sessionInitScopeRef = useRef<string | null>(null);
Expand Down Expand Up @@ -429,10 +442,24 @@ export function useChatPaneController({
});
}, [handleNewChat, needsLegacySessionBootstrap, organizationId]);

const sessionItems = useMemo(
() => sessions.map((item) => toSessionSelectorItem(item)),
[sessions],
);
const sessionItems = useMemo(() => {
const nextItems = sessions.map((item) => toSessionSelectorItem(item));
if (
!isDesktopChatDevMode() ||
!sessionId ||
nextItems.some((item) => item.sessionId === sessionId)
) {
return nextItems;
}
return [
{
sessionId,
title: "",
updatedAt: new Date(),
},
...nextItems,
];
}, [sessionId, sessions]);

const consumeLaunchConfig = useCallback(() => {
setChatLaunchConfig(paneId, null);
Expand Down
Loading
Loading