Unverified 提交 9b2146f3 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Support browser-like tabs for better multi-chat experience (#2619)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2619" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces new cross-cutting chat navigation UI and new tab-state atoms that affect chat selection/creation/deletion flows; risk is mainly UX/state regressions and e2e flakiness rather than security. > > **Overview** > **Adds browser-like chat tabs in the title bar.** Introduces `ChatTabs` with draggable visible tabs, an overflow menu, per-tab close (incl. middle-click), streaming-in-progress indicator, and “new activity” dot, plus i18n strings for accessible labels. > > **Persists and coordinates tab state across navigation.** Adds `recentViewedChatIds`/`closedChatIds` atoms and helpers to keep MRU ordering, prevent explicitly closed tabs from reappearing, prune stale IDs, and choose an adjacent fallback chat when closing the active tab (navigating home if none). > > **Updates chat creation/selection flows and tests.** Refactors `useSelectChat` to optionally preserve tab order; updates Home/CreateApp/ChatHeader/ChatList to use it and invalidate chat queries so new chats appear in tabs immediately; adds stable `new-chat-button` test IDs, new unit tests for tab helpers/atoms, and new Playwright e2e coverage for tab render/switch/close behavior. > > **Build tooling/doc tweaks.** Bumps Node engine requirement to `>=24` and updates `rules/git-workflow.md` guidance for engine conflict resolution and stash handling. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 982cef98ff0d3c1552097a91f59196fd07d47602. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds browser-like tabs to the chat title bar for fast multi-chat navigation. Refines tab behavior, accessibility, and creation/deletion flows so tabs stay accurate, responsive, and preserve tab order. - **New Features** - ChatTabs in TitleBar: MRU ordering; drag-to-reorder (visible tabs); overflow dropdown with live count; streaming spinner; post-stream notification dot; middle-click close; WCAG 24x24 close targets; tooltips; accessible labels and focus-visible rings; i18n labels for “Chat in progress” and “New activity” (en, pt-BR, zh-CN); unit + e2e tests (incl. closed-tab state). - Persistent tab state via Jotai: recentViewedChatIds (cap 100) and closedChatIds so explicitly closed tabs don’t reappear; ensureRecentViewedChatId to keep tab order on route changes; Node engine bumped to >=24. - **Bug Fixes** - Close/selection: adjacent-tab fallback; closing the last tab navigates home; selecting a closed tab re-opens it; clear notifications on close; guard unknown/stale IDs; resolve selection loop; prune stale closed IDs; deleting the selected chat also navigates home. - Flows/accessibility: new-chat in ChatHeader uses correct invalidate/select order so tabs render immediately; route changes use ensureRecentViewedChatId to preserve order; overflow ARIA label uses actual overflow count; ResizeObserver attaches with async loads; title truncation respects max length; stable e2e selectors via new-chat-button; fixed CI npm sync via encoding; updated git workflow docs on stash review and engine conflicts; internal refactor with removeFromClosedSet helper to reduce duplication. <sup>Written for commit 982cef98ff0d3c1552097a91f59196fd07d47602. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com> Co-authored-by: 's avatarclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
上级 beeaef57
import { test, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test";
test("tabs appear after navigating between chats", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
// Chat 1
await po.sendPrompt("[dump] build a todo app");
await po.chatActions.waitForChatCompletion();
// Chat 2
await po.chatActions.clickNewChat();
await po.sendPrompt("[dump] build a weather app");
await po.chatActions.waitForChatCompletion();
// At least one tab should be visible (tabs render once there are recent chats).
const closeButtons = po.page.getByLabel(/^Close tab:/);
await expect(async () => {
const count = await closeButtons.count();
expect(count).toBeGreaterThanOrEqual(1);
}).toPass({ timeout: Timeout.MEDIUM });
});
test("clicking a tab switches to that chat", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
// Chat 1 - send a unique message
await po.sendPrompt("First chat unique message alpha");
await po.chatActions.waitForChatCompletion();
// Chat 2 - send a different unique message
await po.chatActions.clickNewChat();
await po.sendPrompt("Second chat unique message beta");
await po.chatActions.waitForChatCompletion();
// Wait for at least 2 tabs to appear
const closeButtons = po.page.getByLabel(/^Close tab:/);
await expect(async () => {
const count = await closeButtons.count();
expect(count).toBeGreaterThanOrEqual(2);
}).toPass({ timeout: Timeout.MEDIUM });
// We're on chat 2 (active). Find and click the inactive tab to switch to chat 1.
// Each tab is a div[draggable] with a title button + close button. The active tab's title button has aria-current="page".
const inactiveTab = po.page
.locator("div[draggable]")
.filter({ hasNot: po.page.locator('button[aria-current="page"]') });
await inactiveTab.locator("button").first().click();
// After clicking, chat 1's message should be visible
await expect(
po.page.getByText("First chat unique message alpha"),
).toBeVisible({ timeout: Timeout.MEDIUM });
});
test("closing a tab removes it and selects adjacent tab", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
// Chat 1
await po.sendPrompt("First chat message gamma");
await po.chatActions.waitForChatCompletion();
// Chat 2
await po.chatActions.clickNewChat();
await po.sendPrompt("Second chat message delta");
await po.chatActions.waitForChatCompletion();
// Chat 3 (currently active)
await po.chatActions.clickNewChat();
await po.sendPrompt("Third chat message epsilon");
await po.chatActions.waitForChatCompletion();
// Wait for tabs to appear
const closeButtons = po.page.getByLabel(/^Close tab:/);
const initialCount = await (async () => {
let count = 0;
await expect(async () => {
count = await closeButtons.count();
expect(count).toBeGreaterThanOrEqual(2);
}).toPass({ timeout: Timeout.MEDIUM });
return count;
})();
// Close the first tab.
await po.page
.getByLabel(/^Close tab:/)
.first()
.click();
// After closing, tab count should decrease.
await expect(async () => {
const newCount = await closeButtons.count();
expect(newCount).toBe(initialCount - 1);
}).toPass({ timeout: Timeout.MEDIUM });
});
......@@ -54,10 +54,7 @@ export class ChatActions {
clickNewChat({ index = 0 }: { index?: number } = {}) {
// There is two new chat buttons...
return this.page
.getByRole("button", { name: "New Chat" })
.nth(index)
.click();
return this.page.getByTestId("new-chat-button").nth(index).click();
}
private getRetryButton() {
......
差异被折叠。
......@@ -181,8 +181,7 @@
}
},
"engines": {
"node": ">=20",
"npm": "11.8.0"
"node": ">=24"
},
"productName": "dyad"
}
......@@ -89,8 +89,9 @@ If you need to rebase but have uncommitted changes (e.g., package-lock.json from
1. Stash changes: `git stash push -m "Stash changes before rebase"`
2. Rebase: `git rebase upstream/main` (resolve conflicts if needed)
3. Pop stash: `git stash pop`
4. Discard spurious changes like package-lock.json (if package.json unchanged): `git restore package-lock.json`
3. After rebase completes, review stashed changes: `git stash show -p`
4. If stashed changes are spurious (e.g., package-lock.json peer markers when package.json conflicts were resolved during rebase), drop the stash: `git stash drop`
5. Otherwise, pop stash: `git stash pop` and discard spurious changes: `git restore package-lock.json` (if package.json unchanged)
This prevents rebase conflicts from uncommitted changes while preserving any work in progress.
......@@ -101,3 +102,7 @@ When rebasing a PR branch that conflicts with upstream documentation changes (e.
- If upstream has reorganized content (e.g., moved sections to separate `rules/*.md` files), keep upstream's version
- Discard the PR's inline content that conflicts with the new organization
- The PR's documentation changes may need to be re-applied to the new file locations after the rebase
## Resolving package.json engine conflicts
When rebasing causes conflicts in the `engines` field of `package.json` (e.g., node version requirements), accept the incoming change from upstream/main to maintain consistency with the base branch requirements. The same resolution should be applied to the corresponding section in `package-lock.json`.
import { describe, it, expect } from "vitest";
import { createStore } from "jotai";
import {
recentViewedChatIdsAtom,
closedChatIdsAtom,
pushRecentViewedChatIdAtom,
removeRecentViewedChatIdAtom,
pruneClosedChatIdsAtom,
} from "@/atoms/chatAtoms";
import {
applySelectionToOrderedChatIds,
getOrderedRecentChatIds,
getVisibleTabCapacity,
getFallbackChatIdAfterClose,
partitionChatsByVisibleCount,
reorderVisibleChatIds,
} from "@/components/chat/ChatTabs";
import type { ChatSummary } from "@/lib/schemas";
function chat(id: number): ChatSummary {
return {
id,
appId: 1,
title: `Chat ${id}`,
createdAt: new Date(),
};
}
describe("ChatTabs helpers", () => {
it("keeps MRU order and appends chats that were never viewed", () => {
const chats = [chat(1), chat(2), chat(3), chat(4)];
const orderedIds = getOrderedRecentChatIds([4, 2], chats);
expect(orderedIds).toEqual([4, 2, 1, 3]);
});
it("skips stale chat ids that no longer exist", () => {
const chats = [chat(1), chat(3)];
const orderedIds = getOrderedRecentChatIds([3, 999, 1], chats);
expect(orderedIds).toEqual([3, 1]);
});
it("does not reorder when selecting an already-visible tab", () => {
const orderedIds = [4, 2, 3, 1];
const nextIds = applySelectionToOrderedChatIds(orderedIds, 2, 3);
expect(nextIds).toEqual([4, 2, 3, 1]);
});
it("promotes a non-visible selected tab and bumps the last visible tab", () => {
const orderedIds = [4, 2, 3, 1];
const nextIds = applySelectionToOrderedChatIds(orderedIds, 1, 3);
expect(nextIds).toEqual([1, 4, 2, 3]);
});
it("reorders only visible tabs during drag", () => {
const orderedIds = [10, 11, 12, 13, 14];
const nextIds = reorderVisibleChatIds(orderedIds, 3, 12, 10);
expect(nextIds).toEqual([12, 10, 11, 13, 14]);
});
it("partitions chats into visible and overflow sets", () => {
const orderedChats = [chat(1), chat(2), chat(3), chat(4)];
const { visibleTabs, overflowTabs } = partitionChatsByVisibleCount(
orderedChats,
2,
);
expect(visibleTabs.map((c) => c.id)).toEqual([1, 2]);
expect(overflowTabs.map((c) => c.id)).toEqual([3, 4]);
});
it("uses overflow-aware capacity with min width constraints", () => {
// 3 tabs fit at 140px each (+ gaps), but with overflow trigger reserved only 2 fit.
expect(getVisibleTabCapacity(430, 4, 140)).toBe(2);
});
it("selects right-adjacent tab when closing active middle tab", () => {
const fallback = getFallbackChatIdAfterClose(
[chat(1), chat(2), chat(3)],
2,
);
expect(fallback).toBe(3);
});
it("selects previous tab when closing active rightmost tab", () => {
const fallback = getFallbackChatIdAfterClose(
[chat(1), chat(2), chat(3)],
3,
);
expect(fallback).toBe(2);
});
});
describe("recent viewed chat atoms", () => {
it("moves selected chat to front and dedupes", () => {
const store = createStore();
store.set(recentViewedChatIdsAtom, [1, 2, 3]);
store.set(pushRecentViewedChatIdAtom, 2);
expect(store.get(recentViewedChatIdsAtom)).toEqual([2, 1, 3]);
});
it("removes closed tab from tab state only", () => {
const store = createStore();
store.set(recentViewedChatIdsAtom, [3, 2, 1]);
store.set(removeRecentViewedChatIdAtom, 2);
expect(store.get(recentViewedChatIdsAtom)).toEqual([3, 1]);
});
it("adds chat to closedChatIds when removed", () => {
const store = createStore();
store.set(recentViewedChatIdsAtom, [3, 2, 1]);
store.set(removeRecentViewedChatIdAtom, 2);
expect(store.get(closedChatIdsAtom).has(2)).toBe(true);
});
it("removes chat from closedChatIds when pushed", () => {
const store = createStore();
store.set(recentViewedChatIdsAtom, [3, 1]);
store.set(closedChatIdsAtom, new Set([2]));
store.set(pushRecentViewedChatIdAtom, 2);
expect(store.get(closedChatIdsAtom).has(2)).toBe(false);
expect(store.get(recentViewedChatIdsAtom)).toEqual([2, 3, 1]);
});
it("prunes stale IDs from closedChatIds", () => {
const store = createStore();
store.set(closedChatIdsAtom, new Set([1, 2, 99]));
store.set(pruneClosedChatIdsAtom, new Set([1, 2, 3]));
const pruned = store.get(closedChatIdsAtom);
expect(pruned.has(1)).toBe(true);
expect(pruned.has(2)).toBe(true);
expect(pruned.has(99)).toBe(false);
});
});
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useLoadApps } from "@/hooks/useLoadApps";
import { useRouter } from "@tanstack/react-router";
......@@ -22,6 +22,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ChatActivityButton } from "@/components/chat/ChatActivity";
import { ChatTabs } from "@/components/chat/ChatTabs";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { MoreVertical, Cog, Trash2 } from "lucide-react";
import {
DropdownMenu,
......@@ -36,6 +38,7 @@ import { useTranslation } from "react-i18next";
export const TitleBar = () => {
const [selectedAppId] = useAtom(selectedAppIdAtom);
const selectedChatId = useAtomValue(selectedChatIdAtom);
const { apps } = useLoadApps();
const { navigate } = useRouter();
const { settings, refreshSettings } = useSettings();
......@@ -93,8 +96,9 @@ export const TitleBar = () => {
</Button>
{isDyadPro && <DyadProButton isDyadProEnabled={isDyadProEnabled} />}
{/* Spacer to push window controls to the right */}
<div className="flex-1" />
<div className="flex-1 min-w-0 overflow-hidden no-app-region-drag">
<ChatTabs selectedChatId={selectedChatId} />
</div>
<TitleBarActions />
......
import type { FileAttachment, Message, AgentTodo } from "@/ipc/types";
import type { Getter, Setter } from "jotai";
import { atom } from "jotai";
// Per-chat atoms implemented with maps keyed by chatId
......@@ -15,6 +16,107 @@ export const homeChatInputValueAtom = atom<string>("");
// Used for scrolling to the bottom of the chat messages (per chat)
export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map());
export const recentStreamChatIdsAtom = atom<Set<number>>(new Set<number>());
export const recentViewedChatIdsAtom = atom<number[]>([]);
// Track explicitly closed tabs - these should not reappear in the tab bar
export const closedChatIdsAtom = atom<Set<number>>(new Set<number>());
const MAX_RECENT_VIEWED_CHAT_IDS = 100;
// Helper to remove a chat ID from the closed set (used when a closed tab is re-opened)
function removeFromClosedSet(get: Getter, set: Setter, chatId: number): void {
const closedIds = get(closedChatIdsAtom);
if (closedIds.has(chatId)) {
const newClosedIds = new Set(closedIds);
newClosedIds.delete(chatId);
set(closedChatIdsAtom, newClosedIds);
}
}
export const setRecentViewedChatIdsAtom = atom(
null,
(_get, set, chatIds: number[]) => {
if (chatIds.length > MAX_RECENT_VIEWED_CHAT_IDS) {
set(
recentViewedChatIdsAtom,
chatIds.slice(0, MAX_RECENT_VIEWED_CHAT_IDS),
);
} else {
set(recentViewedChatIdsAtom, chatIds);
}
},
);
// Add a chat ID to the recent list only if it's not already present.
// Unlike pushRecentViewedChatIdAtom, this does NOT move existing IDs to the front,
// preserving the current tab order for chats already tracked.
export const ensureRecentViewedChatIdAtom = atom(
null,
(get, set, chatId: number) => {
const currentIds = get(recentViewedChatIdsAtom);
if (currentIds.includes(chatId)) return;
const nextIds = [chatId, ...currentIds];
if (nextIds.length > MAX_RECENT_VIEWED_CHAT_IDS) {
nextIds.length = MAX_RECENT_VIEWED_CHAT_IDS;
}
set(recentViewedChatIdsAtom, nextIds);
// Remove from closed set when explicitly selected
removeFromClosedSet(get, set, chatId);
},
);
export const pushRecentViewedChatIdAtom = atom(
null,
(get, set, chatId: number) => {
const nextIds = get(recentViewedChatIdsAtom).filter((id) => id !== chatId);
nextIds.unshift(chatId);
if (nextIds.length > MAX_RECENT_VIEWED_CHAT_IDS) {
nextIds.length = MAX_RECENT_VIEWED_CHAT_IDS;
}
set(recentViewedChatIdsAtom, nextIds);
// Remove from closed set when explicitly selected
removeFromClosedSet(get, set, chatId);
},
);
export const removeRecentViewedChatIdAtom = atom(
null,
(get, set, chatId: number) => {
set(
recentViewedChatIdsAtom,
get(recentViewedChatIdsAtom).filter((id) => id !== chatId),
);
// Add to closed set so it doesn't reappear
const closedIds = get(closedChatIdsAtom);
const newClosedIds = new Set(closedIds);
newClosedIds.add(chatId);
set(closedChatIdsAtom, newClosedIds);
},
);
// Prune closed chat IDs that no longer exist in the chats list
export const pruneClosedChatIdsAtom = atom(
null,
(get, set, validChatIds: Set<number>) => {
const closedIds = get(closedChatIdsAtom);
let changed = false;
const pruned = new Set<number>();
for (const id of closedIds) {
if (validChatIds.has(id)) {
pruned.add(id);
} else {
changed = true;
}
}
if (changed) {
set(closedChatIdsAtom, pruned);
}
},
);
// Remove a chat ID from all tracking (used when chat is deleted)
export const removeChatIdFromAllTrackingAtom = atom(
null,
(get, set, chatId: number) => {
set(
recentViewedChatIdsAtom,
get(recentViewedChatIdsAtom).filter((id) => id !== chatId),
);
removeFromClosedSet(get, set, chatId);
},
);
export const attachmentsAtom = atom<FileAttachment[]>([]);
......
......@@ -4,8 +4,12 @@ import { useNavigate, useRouterState } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import { PlusCircle, MoreVertical, Trash2, Edit3, Search } from "lucide-react";
import { useAtom } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useAtom, useSetAtom } from "jotai";
import {
selectedChatIdAtom,
removeChatIdFromAllTrackingAtom,
ensureRecentViewedChatIdAtom,
} from "@/atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { dropdownOpenAtom } from "@/atoms/uiAtoms";
import { ipc } from "@/ipc/types";
......@@ -60,17 +64,30 @@ export function ChatList({ show }: { show?: boolean }) {
// search dialog state
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
const { selectChat } = useSelectChat();
const removeChatIdFromAllTracking = useSetAtom(
removeChatIdFromAllTrackingAtom,
);
const ensureRecentViewedChatId = useSetAtom(ensureRecentViewedChatIdAtom);
// Update selectedChatId when route changes
// Update selectedChatId when route changes and ensure chat appears in tabs.
// Uses ensureRecentViewedChatId (not push) to avoid moving existing tabs to
// the front on every navigation, which would defeat preserveTabOrder and
// drag-to-reorder.
useEffect(() => {
if (isChatRoute) {
const id = routerState.location.search.id;
if (id) {
console.log("Setting selected chat id to", id);
setSelectedChatId(id);
const chatId = Number(id);
if (Number.isFinite(chatId) && chatId > 0) {
setSelectedChatId(chatId);
ensureRecentViewedChatId(chatId);
}
}
}, [isChatRoute, routerState.location.search, setSelectedChatId]);
}, [
isChatRoute,
routerState.location.search,
setSelectedChatId,
ensureRecentViewedChatId,
]);
if (!show) {
return;
......@@ -106,15 +123,12 @@ export function ChatList({ show }: { show?: boolean }) {
updateSettings({ selectedChatMode: effectiveDefaultMode });
}
// Navigate to the new chat
setSelectedChatId(chatId);
navigate({
to: "/chat",
search: { id: chatId },
});
// Refresh the chat list
// Refresh the chat list first so the new chat is in the cache
// before selectChat adds it to the tab bar
await invalidateChats();
// Navigate to the new chat (use selectChat so it appears at front of tab bar)
selectChat({ chatId, appId: selectedAppId });
} catch (error) {
// DO A TOAST
showError(t("failedCreateChat", { error: (error as any).toString() }));
......@@ -130,10 +144,13 @@ export function ChatList({ show }: { show?: boolean }) {
await ipc.chat.deleteChat(chatId);
showSuccess(t("chatDeleted"));
// If the deleted chat was selected, navigate to home
// Remove from tab tracking to prevent stale IDs
removeChatIdFromAllTracking(chatId);
// If the deleted chat was selected, navigate to home (matches tab-close behavior)
if (selectedChatId === chatId) {
setSelectedChatId(null);
navigate({ to: "/chat" });
navigate({ to: "/" });
}
// Refresh the chat list
......@@ -185,6 +202,7 @@ export function ChatList({ show }: { show?: boolean }) {
onClick={handleNewChat}
variant="outline"
className="flex items-center justify-start gap-2 mx-2 py-3"
data-testid="new-chat-button"
>
<PlusCircle size={16} />
<span>{t("newChat")}</span>
......
......@@ -13,11 +13,8 @@ import {
} from "@/components/ui/dialog";
import { useCreateApp } from "@/hooks/useCreateApp";
import { useCheckName } from "@/hooks/useCheckName";
import { useSetAtom } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { NEON_TEMPLATE_IDS, Template } from "@/shared/templates";
import { useRouter } from "@tanstack/react-router";
import { useSelectChat } from "@/hooks/useSelectChat";
import { Loader2 } from "lucide-react";
import { neonTemplateHook } from "@/client_logic/template_hook";
......@@ -35,12 +32,11 @@ export function CreateAppDialog({
template,
}: CreateAppDialogProps) {
const { t } = useTranslation(["home", "common"]);
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const [appName, setAppName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { createApp } = useCreateApp();
const { data: nameCheckResult } = useCheckName(appName);
const router = useRouter();
const { selectChat } = useSelectChat();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
......@@ -61,12 +57,8 @@ export function CreateAppDialog({
appName: result.app.name,
});
}
setSelectedAppId(result.app.id);
// Navigate to the new app's first chat
router.navigate({
to: "/chat",
search: { id: result.chatId },
});
// Selecting the new chat seeds recent tab order immediately.
selectChat({ chatId: result.chatId, appId: result.app.id });
setAppName("");
onOpenChange(false);
} catch (error) {
......
......@@ -20,6 +20,7 @@ import {
import { ipc } from "@/ipc/types";
import { useRouter } from "@tanstack/react-router";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useSelectChat } from "@/hooks/useSelectChat";
import { useChats } from "@/hooks/useChats";
import { showError, showSuccess } from "@/lib/toast";
import { useEffect } from "react";
......@@ -48,8 +49,9 @@ export function ChatHeader({
const appId = useAtomValue(selectedAppIdAtom);
const { versions, loading: versionsLoading } = useVersions(appId);
const { navigate } = useRouter();
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
const [selectedChatId] = useAtom(selectedChatIdAtom);
const { invalidateChats } = useChats(appId);
const { selectChat } = useSelectChat();
const { isStreaming } = useStreamChat();
const isAnyCheckoutVersionInProgress = useAtomValue(
isAnyCheckoutVersionInProgressAtom,
......@@ -87,12 +89,8 @@ export function ChatHeader({
if (appId) {
try {
const chatId = await ipc.chat.createChat(appId);
setSelectedChatId(chatId);
navigate({
to: "/chat",
search: { id: chatId },
});
await invalidateChats();
selectChat({ chatId, appId });
} catch (error) {
showError(t("failedCreateChat", { error: (error as any).toString() }));
}
......@@ -194,6 +192,7 @@ export function ChatHeader({
onClick={handleNewChat}
variant="ghost"
className="hidden @2xs:flex items-center justify-start gap-2 mx-2 py-3"
data-testid="new-chat-button"
>
<PlusCircle size={16} />
<span>{t("newChat")}</span>
......
差异被折叠。
......@@ -18,6 +18,9 @@ export function useCreateApp() {
onSuccess: () => {
// Invalidate apps list to trigger refetch
queryClient.invalidateQueries({ queryKey: queryKeys.apps.all });
// Creating an app also creates the first chat, so refresh the chat list
// so ChatTabs can see it immediately.
queryClient.invalidateQueries({ queryKey: queryKeys.chats.all });
},
onError: (error) => {
showError(error);
......
import { useSetAtom } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import {
selectedChatIdAtom,
pushRecentViewedChatIdAtom,
} from "@/atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useNavigate } from "@tanstack/react-router";
export function useSelectChat() {
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const pushRecentViewedChatId = useSetAtom(pushRecentViewedChatIdAtom);
const navigate = useNavigate();
return {
selectChat: ({ chatId, appId }: { chatId: number; appId: number }) => {
selectChat: ({
chatId,
appId,
preserveTabOrder = false,
}: {
chatId: number;
appId: number;
preserveTabOrder?: boolean;
}) => {
setSelectedChatId(chatId);
setSelectedAppId(appId);
if (!preserveTabOrder) {
pushRecentViewedChatId(chatId);
}
navigate({
to: "/chat",
search: { id: chatId },
......
......@@ -2,6 +2,10 @@
"newChat": "New Chat",
"recentChats": "Recent Chats",
"searchChats": "Search chats",
"closeChatTab": "Close tab: {{title}}",
"chatInProgress": "Chat in progress",
"newActivity": "New activity",
"openOverflowTabs": "Open more tabs ({{count}})",
"loadingChats": "Loading chats...",
"noChatsFound": "No chats found",
"renameChat": "Rename Chat",
......
......@@ -2,6 +2,10 @@
"newChat": "Novo Chat",
"recentChats": "Chats Recentes",
"searchChats": "Pesquisar chats",
"closeChatTab": "Fechar aba: {{title}}",
"chatInProgress": "Chat em andamento",
"newActivity": "Nova atividade",
"openOverflowTabs": "Abrir mais abas ({{count}})",
"loadingChats": "Carregando chats...",
"noChatsFound": "Nenhum chat encontrado",
"renameChat": "Renomear Chat",
......
......@@ -2,6 +2,10 @@
"newChat": "新建聊天",
"recentChats": "最近的聊天",
"searchChats": "搜索聊天",
"closeChatTab": "关闭标签页:{{title}}",
"chatInProgress": "聊天进行中",
"newActivity": "新活动",
"openOverflowTabs": "打开更多标签页({{count}})",
"loadingChats": "正在加载聊天...",
"noChatsFound": "未找到聊天",
"renameChat": "重命名聊天",
......
......@@ -2,7 +2,6 @@ import { useTranslation } from "react-i18next";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { useAtom, useSetAtom } from "jotai";
import { homeChatInputValueAtom } from "../atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { ipc } from "@/ipc/types";
import { generateCuteAppName } from "@/lib/utils";
import { useLoadApps } from "@/hooks/useLoadApps";
......@@ -30,7 +29,9 @@ import { ImportAppButton } from "@/components/ImportAppButton";
import { showError } from "@/lib/toast";
import { invalidateAppQuery } from "@/hooks/useLoadApp";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import { ForceCloseDialog } from "@/components/ForceCloseDialog";
import { useSelectChat } from "@/hooks/useSelectChat";
import type { FileAttachment } from "@/ipc/types";
import { NEON_TEMPLATE_IDS } from "@/shared/templates";
......@@ -53,12 +54,12 @@ export default function HomePage() {
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
const navigate = useNavigate();
const search = useSearch({ from: "/" });
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const { refreshApps } = useLoadApps();
const { settings, updateSettings, envVars } = useSettings();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
const { selectChat } = useSelectChat();
const [isLoading, setIsLoading] = useState(false);
const [forceCloseDialogOpen, setForceCloseDialogOpen] = useState(false);
const [performanceData, setPerformanceData] = useState<any>(undefined);
......@@ -200,12 +201,14 @@ export default function HomePage() {
);
setInputValue("");
setSelectedAppId(result.app.id);
setIsPreviewOpen(false);
await refreshApps(); // Ensure refreshApps is awaited if it's async
await invalidateAppQuery(queryClient, { appId: result.app.id });
// Invalidate chats so ChatTabs picks up the new chat immediately.
await queryClient.invalidateQueries({ queryKey: queryKeys.chats.all });
posthog.capture("home:chat-submit");
navigate({ to: "/chat", search: { id: result.chatId } });
// Select newly created first chat so it appears first in tabs.
selectChat({ chatId: result.chatId, appId: result.app.id });
} catch (error) {
console.error("Failed to create chat:", error);
showError(t("failedCreateApp", { error: (error as any).toString() }));
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论