Skip to content

Commit 9a3543a

Browse files
committed
Improve draft chat empty states and stale context menus
- Add branded draft empty states with recent threads and starter prompts - Keep draft source-control navigation state consistent across routes - Prevent overlapping spelling lookups from opening stale menus - Add regression coverage for newest-menu behavior
1 parent 7485d88 commit 9a3543a

13 files changed

Lines changed: 622 additions & 280 deletions

apps/desktop/src/window/DesktopWindow.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as NodeServices from "@effect/platform-node/NodeServices";
22
import { assert, describe, it } from "@effect/vitest";
3+
import * as Deferred from "effect/Deferred";
34
import * as Effect from "effect/Effect";
45
import * as FileSystem from "effect/FileSystem";
56
import * as Layer from "effect/Layer";
@@ -416,6 +417,76 @@ describe("DesktopWindow", () => {
416417
}),
417418
);
418419

420+
it.effect("only opens the newest spelling menu when platform lookups overlap", () =>
421+
Effect.gen(function* () {
422+
const fakeWindow = makeFakeBrowserWindow();
423+
const createCount = yield* Ref.make(0);
424+
const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
425+
const firstSuggestions = yield* Deferred.make<ReadonlyArray<string>>();
426+
const secondSuggestions = yield* Deferred.make<ReadonlyArray<string>>();
427+
const platformSuggestionsFor = vi.fn((word: string) =>
428+
Deferred.await(word === "firstt" ? firstSuggestions : secondSuggestions),
429+
);
430+
const popupTemplate = vi.fn((_input: ElectronMenu.ElectronMenuTemplateInput) => Effect.void);
431+
const layer = makeTestLayer({
432+
window: fakeWindow.window,
433+
createCount,
434+
mainWindow,
435+
electronMenu: {
436+
setApplicationMenu: () => Effect.void,
437+
popupTemplate,
438+
showContextMenu: () => Effect.succeed(Option.none()),
439+
},
440+
electronSpelling: { platformSuggestionsFor },
441+
});
442+
443+
yield* Effect.gen(function* () {
444+
const desktopWindow = yield* DesktopWindow.DesktopWindow;
445+
yield* desktopWindow.handleBackendReady;
446+
447+
const contextMenuParams = (misspelledWord: string) =>
448+
({
449+
misspelledWord,
450+
dictionarySuggestions: [],
451+
linkURL: "",
452+
mediaType: "none",
453+
editFlags: {
454+
canUndo: false,
455+
canRedo: false,
456+
canCut: true,
457+
canCopy: true,
458+
canPaste: true,
459+
canDelete: false,
460+
canSelectAll: true,
461+
canEditRichly: false,
462+
},
463+
frame: {} as Electron.WebFrameMain,
464+
menuSourceType: "mouse",
465+
}) satisfies Partial<Electron.ContextMenuParams>;
466+
467+
fakeWindow.emitWebContents(
468+
"context-menu",
469+
{ preventDefault: vi.fn() },
470+
contextMenuParams("firstt"),
471+
);
472+
fakeWindow.emitWebContents(
473+
"context-menu",
474+
{ preventDefault: vi.fn() },
475+
contextMenuParams("secondd"),
476+
);
477+
478+
yield* Deferred.succeed(secondSuggestions, ["second"]);
479+
yield* waitForMockCalls(popupTemplate);
480+
assert.equal(popupTemplate.mock.calls.length, 1);
481+
assert.equal(popupTemplate.mock.calls[0]?.[0].template[0]?.label, "second");
482+
483+
yield* Deferred.succeed(firstSuggestions, ["first"]);
484+
yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
485+
assert.equal(popupTemplate.mock.calls.length, 1);
486+
}).pipe(Effect.provide(layer));
487+
}),
488+
);
489+
419490
it.effect("falls back to No suggestions when the platform has none either", () =>
420491
Effect.gen(function* () {
421492
const fakeWindow = makeFakeBrowserWindow();

apps/desktop/src/window/DesktopWindow.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,8 +354,10 @@ const make = Effect.gen(function* () {
354354
);
355355
});
356356

357+
let contextMenuRequestVersion = 0;
357358
window.webContents.on("context-menu", (event, params) => {
358359
event.preventDefault();
360+
const requestVersion = ++contextMenuRequestVersion;
359361

360362
void runPromise(
361363
Effect.gen(function* () {
@@ -370,10 +372,16 @@ const make = Effect.gen(function* () {
370372
params.dictionarySuggestions.length > 0
371373
? params.dictionarySuggestions
372374
: yield* electronSpelling.platformSuggestionsFor(params.misspelledWord);
375+
if (requestVersion !== contextMenuRequestVersion) {
376+
return;
377+
}
373378
for (const suggestion of suggestions.slice(0, 5)) {
374379
menuTemplate.push({
375380
label: suggestion,
376381
click: () => {
382+
if (requestVersion !== contextMenuRequestVersion) {
383+
return;
384+
}
377385
window.webContents.replaceMisspelling(suggestion);
378386
window.webContents.focus();
379387
},

apps/web/src/components/ChatView.tsx

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ import { isElectron } from "../env";
4747
import { ensureLocalApi, readLocalApi } from "../localApi";
4848
import {
4949
closeRightPanelSearchParams,
50+
isDraftSourceControlPanelOpen,
5051
isSourceControlPanelOpen,
5152
parseDiffRouteSearch,
52-
preserveRightPanelSearchParamsForNavigation,
53+
preserveRightPanelSearchParamsForDraftNavigation,
5354
stripRightPanelSearchParams,
5455
} from "../diffRouteSearch";
5556
import {
@@ -190,6 +191,7 @@ import { getComposerProviderState } from "./chat/composerProviderState";
190191
import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
191192
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
192193
import { MessagesTimeline, type TimelineProposedPlanState } from "./chat/MessagesTimeline";
194+
import { DraftEmptyState } from "./chat/DraftEmptyState";
193195
import { ProviderModelPicker } from "./chat/ProviderModelPicker";
194196
import { ChatHeader, type ForkHeaderContext } from "./chat/ChatHeader";
195197
import type { ThreadBackgroundRunItem } from "./chat/ThreadActivityPopover";
@@ -1261,19 +1263,17 @@ export default function ChatView(props: ChatViewProps) {
12611263
composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
12621264
const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
12631265
const canCheckoutPullRequestIntoThread = isLocalDraftThread;
1264-
const sourceControlOpen = isSourceControlPanelOpen(rawSearch, {
1265-
defaultOpen: !shouldUseRightPanelSheet,
1266-
});
1266+
// Mirror the owning route's panel default: server threads open on wide
1267+
// viewports, drafts stay closed until explicitly opened.
1268+
const sourceControlOpen =
1269+
routeKind === "server"
1270+
? isSourceControlPanelOpen(rawSearch, { defaultOpen: !shouldUseRightPanelSheet })
1271+
: isDraftSourceControlPanelOpen(rawSearch);
12671272
// The diff panel is a drill-in of source control, so the header toggle
12681273
// treats the right panel as one unit: it stays pressed while a diff is
12691274
// open and pressing it closes the whole panel.
12701275
const diffPanelOpen = rawSearch.diff === "1";
12711276
const rightPanelEngaged = sourceControlOpen || diffPanelOpen;
1272-
const preserveRightPanelSearchForDraftNavigation = useCallback(
1273-
(previous: Record<string, unknown>) =>
1274-
preserveRightPanelSearchParamsForNavigation(previous, { sourceControlOpen }),
1275-
[sourceControlOpen],
1276-
);
12771277
const activeThreadId = activeThread?.id ?? null;
12781278
const activeThreadRef = useMemo(
12791279
() => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
@@ -1323,6 +1323,34 @@ export default function ChatView(props: ChatViewProps) {
13231323
// General Chat threads run in a hidden scratch workspace: source-control,
13241324
// scripts, and open-in affordances stay hidden even though a project exists.
13251325
const isGeneralChatThread = activeProject?.kind === "general-chat";
1326+
const insertDraftStarterPrompt = useCallback(
1327+
(text: string) => {
1328+
setComposerDraftPrompt(composerDraftTarget, text);
1329+
window.requestAnimationFrame(() => {
1330+
composerRef.current?.focusAtEnd();
1331+
});
1332+
},
1333+
[composerDraftTarget, composerRef, setComposerDraftPrompt],
1334+
);
1335+
const draftTimelineEmptyState = useMemo(
1336+
() =>
1337+
isLocalDraftThread && draftThread ? (
1338+
<DraftEmptyState
1339+
currentProjectRef={scopeProjectRef(draftThread.environmentId, draftThread.projectId)}
1340+
currentProjectName={activeProject?.name ?? null}
1341+
isGeneralChat={isGeneralChatThread}
1342+
onInsertPrompt={insertDraftStarterPrompt}
1343+
/>
1344+
) : undefined,
1345+
[
1346+
activeProject?.name,
1347+
draftThread?.environmentId,
1348+
draftThread?.projectId,
1349+
insertDraftStarterPrompt,
1350+
isGeneralChatThread,
1351+
isLocalDraftThread,
1352+
],
1353+
);
13261354

13271355
const shouldRetainThreadDetailSubscription = routeKind === "server" || serverThread !== undefined;
13281356
useEffect(() => {
@@ -1503,7 +1531,7 @@ export default function ChatView(props: ChatViewProps) {
15031531
await navigate({
15041532
to: "/draft/$draftId",
15051533
params: buildDraftThreadRouteParams(storedDraftSession.draftId),
1506-
search: preserveRightPanelSearchForDraftNavigation,
1534+
search: preserveRightPanelSearchParamsForDraftNavigation,
15071535
});
15081536
}
15091537
return storedDraftSession.threadId;
@@ -1538,7 +1566,7 @@ export default function ChatView(props: ChatViewProps) {
15381566
await navigate({
15391567
to: "/draft/$draftId",
15401568
params: buildDraftThreadRouteParams(nextDraftId),
1541-
search: preserveRightPanelSearchForDraftNavigation,
1569+
search: preserveRightPanelSearchParamsForDraftNavigation,
15421570
});
15431571
return nextThreadId;
15441572
},
@@ -1549,7 +1577,6 @@ export default function ChatView(props: ChatViewProps) {
15491577
getDraftSessionByLogicalProjectKey,
15501578
isServerThread,
15511579
navigate,
1552-
preserveRightPanelSearchForDraftNavigation,
15531580
projectGroupingSettings,
15541581
routeKind,
15551582
setDraftThreadContext,
@@ -3764,9 +3791,13 @@ export default function ChatView(props: ChatViewProps) {
37643791
setIsRevertingCheckpoint(false);
37653792
}, [activeThread?.id]);
37663793

3794+
// Pristine drafts (the home surface) open without stealing focus; the
3795+
// composer takes focus once a conversation exists or via an explicit action.
3796+
const skipEntryComposerFocus = isLocalDraftThread && (activeThread?.messages.length ?? 0) === 0;
37673797
useEffect(() => {
37683798
if (
37693799
!activeThread?.id ||
3800+
skipEntryComposerFocus ||
37703801
timelineSearchTargetRef.current ||
37713802
terminalState.terminalOpen ||
37723803
isCoarsePointer
@@ -3779,7 +3810,13 @@ export default function ChatView(props: ChatViewProps) {
37793810
return () => {
37803811
window.cancelAnimationFrame(frame);
37813812
};
3782-
}, [activeThread?.id, focusComposer, isCoarsePointer, terminalState.terminalOpen]);
3813+
}, [
3814+
activeThread?.id,
3815+
focusComposer,
3816+
isCoarsePointer,
3817+
skipEntryComposerFocus,
3818+
terminalState.terminalOpen,
3819+
]);
37833820

37843821
useEffect(() => {
37853822
if (!activeThread?.id) return;
@@ -6058,6 +6095,7 @@ export default function ChatView(props: ChatViewProps) {
60586095
{/* Messages — LegendList handles virtualization and scrolling internally */}
60596096
<MessagesTimeline
60606097
key={activeThread.id}
6098+
emptyState={draftTimelineEmptyState}
60616099
isWorking={isWorking}
60626100
activeStatusLabel={activeStatusLabel}
60636101
activeTurnInProgress={isWorking || !latestTurnSettled}

0 commit comments

Comments
 (0)