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
23 changes: 23 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
hasServerAcknowledgedLocalDispatch,
reconcileMountedTerminalThreadIds,
reconcileRetainedMountedThreadIds,
requiresDraftProjectSelection,
resolveEffectiveServerThreadWorktreePath,
resolveSendEnvMode,
shouldWriteThreadErrorToCurrentServerThread,
Expand Down Expand Up @@ -78,6 +79,28 @@ const readySession = {
updatedAt: "2026-03-29T00:00:10.000Z",
};

describe("requiresDraftProjectSelection", () => {
it("keeps standalone local drafts available without a project", () => {
expect(
requiresDraftProjectSelection({
isLocalDraftThread: true,
projectId: null,
hasActiveProject: false,
}),
).toBe(false);
});

it("requires a project when a local project draft becomes orphaned", () => {
expect(
requiresDraftProjectSelection({
isLocalDraftThread: true,
projectId,
hasActiveProject: false,
}),
).toBe(true);
});
});

describe("buildThreadTurnInterruptInput", () => {
it("targets the session's active running turn", () => {
const activeTurnId = TurnId.make("turn-running");
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ export function buildLocalDraftThread(
};
}

export function requiresDraftProjectSelection(input: {
readonly isLocalDraftThread: boolean;
readonly projectId: ProjectId | null | undefined;
readonly hasActiveProject: boolean;
}): boolean {
return input.isLocalDraftThread && input.projectId !== null && !input.hasActiveProject;
}

export function shouldWriteThreadErrorToCurrentServerThread(input: {
serverThread:
| {
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ import {
deriveLockedProvider,
readFileAsDataUrl,
reconcileMountedTerminalThreadIds,
requiresDraftProjectSelection,
resolveEffectiveServerThreadWorktreePath,
resolveSendEnvMode,
revokeBlobPreviewUrl,
Expand Down Expand Up @@ -1525,6 +1526,12 @@ function ChatViewContent(props: ChatViewProps) {
? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
: null;
const activeProject = useProject(activeProjectRef);
const isStandaloneThread = activeThread?.projectId === null;
const projectSelectionRequired = requiresDraftProjectSelection({
isLocalDraftThread,
projectId: activeThread?.projectId,
hasActiveProject: activeProject !== null,
});
const activeEnvironmentShell = useEnvironmentQuery(
activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null,
);
Expand Down Expand Up @@ -4300,7 +4307,6 @@ function ChatViewContent(props: ChatViewProps) {
}
return;
}
const isStandaloneThread = activeThread.projectId === null;
if (!activeProject && !isStandaloneThread) {
toastManager.add(
stackedThreadToast({
Expand Down Expand Up @@ -5596,6 +5602,7 @@ function ChatViewContent(props: ChatViewProps) {
<DraftHeroHeadline
activeProjectRef={activeProjectRef}
activeProjectTitle={activeProject?.title ?? null}
isStandalone={isStandaloneThread}
/>
</div>
<ComposerBannerStack className="relative z-0" items={composerBannerItems} />
Expand Down Expand Up @@ -5628,7 +5635,7 @@ function ChatViewContent(props: ChatViewProps) {
isServerThread={isServerThread}
isLocalDraftThread={isLocalDraftThread}
forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState}
projectSelectionRequired={isLocalDraftThread && activeProject === null}
projectSelectionRequired={projectSelectionRequired}
phase={phase}
isConnecting={isConnecting}
isSendBusy={isSendBusy}
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/chat/DraftHeroHeadline.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";

import { DraftHeroHeadline } from "./DraftHeroHeadline";

describe("DraftHeroHeadline", () => {
it("does not ask standalone chats to choose a project", () => {
const markup = renderToStaticMarkup(
<DraftHeroHeadline activeProjectRef={null} activeProjectTitle={null} isStandalone />,
);

expect(markup).toContain("What can I help you with?");
expect(markup).not.toContain("Choose a project");
});
});
22 changes: 22 additions & 0 deletions apps/web/src/components/chat/DraftHeroHeadline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,34 @@ import {
interface DraftHeroHeadlineProps {
readonly activeProjectRef: ScopedProjectRef | null;
readonly activeProjectTitle: string | null;
readonly isStandalone: boolean;
}

export function DraftHeroHeadline({
activeProjectRef,
activeProjectTitle,
isStandalone,
}: DraftHeroHeadlineProps) {
if (isStandalone) {
return (
<h1 className="mx-auto w-full max-w-5xl text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl">
What can I help you with?
</h1>
);
}

return (
<ProjectDraftHeroHeadline
activeProjectRef={activeProjectRef}
activeProjectTitle={activeProjectTitle}
/>
);
}

function ProjectDraftHeroHeadline({
activeProjectRef,
activeProjectTitle,
}: Omit<DraftHeroHeadlineProps, "isStandalone">) {
const projects = useProjects();
const threads = useThreadShells();
const handleNewThread = useNewThreadHandler();
Expand Down
2 changes: 2 additions & 0 deletions docs/personal-fork-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ Upstream PRs integrated into the fork are listed in
- Creating a standalone chat opens a local draft immediately, matching project-thread creation;
the server thread is materialized atomically with the first message instead of blocking
navigation on an empty-thread request.
- Standalone drafts remain writable in the shared new-thread composer and use projectless hero
copy; missing-project guards continue to apply only to orphaned project drafts.
- Standalone chats participate in the same desktop completion sounds and macOS notifications as
project threads. When agent-activity publishing is enabled, they also publish completion and
attention states to connected mobile clients under the generic `Chats` activity group.
Expand Down
Loading