(null);
+ const ignoreNextTitleBlurRef = useRef(false);
const {
teamUsage,
@@ -434,6 +440,46 @@ const Conversations = ({
}
};
+ const handleStartEditTitle = () => {
+ if (!selectedThreadId) return;
+ const thr = threads.find(t => t.id === selectedThreadId);
+ debug('[chat] thread rename: start thread=%s', selectedThreadId);
+ setEditTitleValue(thr?.title ?? '');
+ ignoreNextTitleBlurRef.current = true;
+ setEditingTitle(true);
+ const scheduleSelect = window.requestAnimationFrame ?? window.setTimeout;
+ scheduleSelect(() => {
+ editTitleInputRef.current?.select();
+ ignoreNextTitleBlurRef.current = false;
+ });
+ };
+
+ const handleCommitTitle = () => {
+ const trimmed = editTitleValue.trim();
+ setEditingTitle(false);
+ // Title length only — never log the title text itself (may carry PII).
+ if (!selectedThreadId || !trimmed) {
+ debug('[chat] thread rename: commit skipped thread=%s empty=%s', selectedThreadId, !trimmed);
+ return;
+ }
+ const currentTitle = threads.find(t => t.id === selectedThreadId)?.title?.trim();
+ if (trimmed === currentTitle) {
+ debug('[chat] thread rename: commit skipped thread=%s (unchanged)', selectedThreadId);
+ return;
+ }
+ debug('[chat] thread rename: commit thread=%s len=%d', selectedThreadId, trimmed.length);
+ void dispatch(updateThreadTitle({ threadId: selectedThreadId, title: trimmed }))
+ .unwrap()
+ .then(() => debug('[chat] thread rename: committed thread=%s', selectedThreadId))
+ .catch(err =>
+ debug(
+ '[chat] thread rename: failed thread=%s err=%s',
+ selectedThreadId,
+ err instanceof Error ? err.message : String(err)
+ )
+ );
+ };
+
const handleSelectAgentProfile = async (profileId: string) => {
try {
await dispatch(selectAgentProfile(profileId)).unwrap();
@@ -2468,6 +2514,67 @@ const Conversations = ({
)}
+ {/* Thread title + inline rename (page variant). Hover the title to
+ reveal the edit affordance; Enter commits, Escape cancels. */}
+ {!isSidebar && selectedThreadId && (
+
+ {editingTitle ? (
+
setEditTitleValue(e.target.value)}
+ onKeyDown={e => {
+ // Ignore the Enter that confirms an IME composition candidate
+ // (CJK input) so it doesn't prematurely commit the rename.
+ if (isImeCompositionKeyEvent(e)) return;
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleCommitTitle();
+ } else if (e.key === 'Escape') {
+ setEditingTitle(false);
+ }
+ }}
+ onBlur={() => {
+ if (ignoreNextTitleBlurRef.current) {
+ ignoreNextTitleBlurRef.current = false;
+ return;
+ }
+ handleCommitTitle();
+ }}
+ aria-label={t('chat.editThreadTitle')}
+ className="h-6 w-full min-w-0 border-b border-primary-400 bg-transparent py-0 text-sm font-medium leading-none text-stone-700 outline-none dark:text-neutral-200"
+ autoFocus
+ />
+ ) : (
+
+
+ {resolveThreadDisplayTitle(selectedThreadId)}
+
+
+
+ )}
+
+ )}
+
{/* Model + token stats (left) and the quick/reasoning toggle + files
chip (right) share one line. */}
{
});
});
});
+
+describe('Conversations — thread rename', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
+ mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
+ });
+
+ it('commits an inline thread-title rename from the conversation header', async () => {
+ const { thread } = await renderWithSelectedThread();
+ const { threadApi } = await import('../../services/api/threadApi');
+
+ // Enter edit mode via the header pencil affordance.
+ fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' }));
+ const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
+ fireEvent.change(input, { target: { value: 'Renamed in header' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+
+ await waitFor(() => {
+ expect(threadApi.updateTitle).toHaveBeenCalledWith(thread.id, 'Renamed in header');
+ });
+ });
+
+ it('cancels the rename on Escape without dispatching an update', async () => {
+ await renderWithSelectedThread();
+ const { threadApi } = await import('../../services/api/threadApi');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' }));
+ const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
+ fireEvent.change(input, { target: { value: 'Discarded title' } });
+ fireEvent.keyDown(input, { key: 'Escape' });
+
+ // Editor closes back to the title heading; no persistence call fired.
+ await waitFor(() => {
+ expect(screen.queryByRole('textbox', { name: 'Edit thread title' })).toBeNull();
+ });
+ expect(threadApi.updateTitle).not.toHaveBeenCalled();
+ });
+
+ it('does not commit on the Enter that confirms an IME composition', async () => {
+ await renderWithSelectedThread();
+ const { threadApi } = await import('../../services/api/threadApi');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' }));
+ const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
+ fireEvent.change(input, { target: { value: '日本語' } });
+ // keyCode 229 marks an IME composition keydown — Enter here confirms a
+ // candidate, not the rename.
+ fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 });
+
+ expect(threadApi.updateTitle).not.toHaveBeenCalled();
+ // Editor stays open for continued composition.
+ expect(screen.getByRole('textbox', { name: 'Edit thread title' })).toBeInTheDocument();
+ });
+
+ it('skips persistence when the committed title is unchanged', async () => {
+ await renderWithSelectedThread();
+ const { threadApi } = await import('../../services/api/threadApi');
+
+ // The input seeds with the current title ("Attach Thread"); committing it
+ // unchanged must not dispatch an update.
+ fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' }));
+ const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
+ fireEvent.keyDown(input, { key: 'Enter' });
+
+ await waitFor(() => {
+ expect(screen.queryByRole('textbox', { name: 'Edit thread title' })).toBeNull();
+ });
+ expect(threadApi.updateTitle).not.toHaveBeenCalled();
+ });
+
+ it('skips persistence when the committed title is blank', async () => {
+ await renderWithSelectedThread();
+ const { threadApi } = await import('../../services/api/threadApi');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' }));
+ const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
+ fireEvent.change(input, { target: { value: ' ' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+
+ await waitFor(() => {
+ expect(screen.queryByRole('textbox', { name: 'Edit thread title' })).toBeNull();
+ });
+ expect(threadApi.updateTitle).not.toHaveBeenCalled();
+ });
+
+ it('opens the editor via mousedown and ignores the immediate focus-blur', async () => {
+ await renderWithSelectedThread();
+ const { threadApi } = await import('../../services/api/threadApi');
+
+ // onMouseDown opens edit mode; the blur fired while the input is grabbing
+ // focus is ignored (no spurious commit).
+ fireEvent.mouseDown(screen.getByRole('button', { name: 'Edit thread title' }));
+ const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
+ fireEvent.blur(input);
+
+ expect(threadApi.updateTitle).not.toHaveBeenCalled();
+ });
+
+ it('swallows a rename persistence failure without crashing', async () => {
+ const { thread } = await renderWithSelectedThread();
+ const { threadApi } = await import('../../services/api/threadApi');
+ (threadApi.updateTitle as ReturnType).mockRejectedValueOnce(
+ new Error('rename boom')
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' }));
+ const input = await screen.findByRole('textbox', { name: 'Edit thread title' });
+ fireEvent.change(input, { target: { value: 'Doomed rename' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+
+ await waitFor(() => {
+ expect(threadApi.updateTitle).toHaveBeenCalledWith(thread.id, 'Doomed rename');
+ });
+ // The editor still closes and the UI stays mounted.
+ await waitFor(() => {
+ expect(screen.queryByRole('textbox', { name: 'Edit thread title' })).toBeNull();
+ });
+ });
+});
diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts
index de766eb2b..a6bfe3cba 100644
--- a/app/src/test/setup.ts
+++ b/app/src/test/setup.ts
@@ -8,7 +8,7 @@
* - Resets rate limiter module-level state between tests
*/
import '@testing-library/jest-dom/vitest';
-import { cleanup } from '@testing-library/react';
+import { cleanup, configure } from '@testing-library/react';
import type React from 'react';
import { afterAll, afterEach, beforeEach, vi } from 'vitest';
@@ -20,6 +20,18 @@ import {
stopMockServer,
} from '../../../scripts/mock-api-core.mjs';
+// The full Vitest run is executed under v8 coverage instrumentation with a
+// single worker (see test/vitest.config.ts), which makes individual renders
+// markedly slower than an isolated, un-instrumented file run. Testing Library's
+// default 1000ms async-utility timeout is too tight in that environment, so
+// `findBy*`/`waitFor` assertions in render-heavy suites (e.g. the workflow
+// orchestration tab) flake intermittently — and which test trips first is
+// non-deterministic. Raising the global async-util budget removes that whole
+// class of false timeouts without masking real failures: `waitFor`/`findBy`
+// still resolve the instant their condition is met, this only widens the
+// ceiling before they give up (well within the 30s testTimeout).
+configure({ asyncUtilTimeout: 5000 });
+
const DEFAULT_TEST_MOCK_API_PORT = 5005;
function readMockApiPort() {
diff --git a/app/test/playwright/helpers/core-rpc.ts b/app/test/playwright/helpers/core-rpc.ts
index 43fdaac54..1a0b9e094 100644
--- a/app/test/playwright/helpers/core-rpc.ts
+++ b/app/test/playwright/helpers/core-rpc.ts
@@ -78,13 +78,19 @@ async function completeAuthCallback(page: Page, token: string): Promise {
await page.goto(`/#/callback/auth?token=${encodeURIComponent(token)}&key=auth`);
try {
// The app-side auth callback waits up to 15s for CoreStateProvider to
- // commit currentUser before navigating to /home; CI occasionally needs
- // more than Playwright's default 10s assertion window here.
+ // commit currentUser before navigating to the post-auth landing surface;
+ // CI occasionally needs more than Playwright's default 10s assertion
+ // window here. Since the root two-panel shell folded Home into the unified
+ // chat surface, the post-auth landing is /chat (the former /home route now
+ // redirects there). We must wait for the *settled* #/chat rather than the
+ // transient #/home redirect frame — otherwise callers that navigate
+ // immediately after sign-in can have their target clobbered by the late
+ // /home → /chat redirect.
await expect
.poll(async () => page.evaluate(() => window.location.hash), {
timeout: AUTH_CALLBACK_HOME_TIMEOUT_MS,
})
- .toMatch(/^#\/home/);
+ .toMatch(/^#\/chat/);
return;
} catch {
const runtimePickerVisible = await page
@@ -94,7 +100,7 @@ async function completeAuthCallback(page: Page, token: string): Promise {
.catch(() => false);
if (!runtimePickerVisible) {
throw new Error(
- 'auth callback did not reach /home and no runtime picker fallback was available'
+ 'auth callback did not reach the post-auth landing surface (/home → /chat) and no runtime picker fallback was available'
);
}
}
@@ -105,7 +111,7 @@ async function completeAuthCallback(page: Page, token: string): Promise {
.poll(async () => page.evaluate(() => window.location.hash), {
timeout: AUTH_CALLBACK_HOME_TIMEOUT_MS,
})
- .toMatch(/^#\/home/);
+ .toMatch(/^#\/chat/);
}
export async function resetCoreForWebGuest(): Promise {
diff --git a/app/test/playwright/specs/agent-review.spec.ts b/app/test/playwright/specs/agent-review.spec.ts
index 7504725c8..e08b425fd 100644
--- a/app/test/playwright/specs/agent-review.spec.ts
+++ b/app/test/playwright/specs/agent-review.spec.ts
@@ -23,11 +23,11 @@ test.describe('Agent review - canonical onboarding + privacy flow', () => {
test('launches, reaches the shell, and opens the privacy panel', async ({ page }) => {
await bootReviewedFlow(page, 'pw-agent-review');
+ // Home folded into the unified chat surface: post-login landing is /chat,
+ // whose sidebar/new-window state renders these structural markers.
const shellText = await page.locator('#root').innerText();
expect(
- ['Ask your assistant anything', 'Your device is connected', 'Settings', 'Home'].some(marker =>
- shellText.includes(marker)
- )
+ ['New Conversation', 'Threads', 'Settings'].some(marker => shellText.includes(marker))
).toBe(true);
await page.goto('/#/settings/privacy');
diff --git a/app/test/playwright/specs/auth-access-control.spec.ts b/app/test/playwright/specs/auth-access-control.spec.ts
index ac0858677..1323f1536 100644
--- a/app/test/playwright/specs/auth-access-control.spec.ts
+++ b/app/test/playwright/specs/auth-access-control.spec.ts
@@ -45,7 +45,9 @@ test.describe('Auth & Access Control', () => {
test('authenticated sign-in reaches home', async ({ page }) => {
await signInViaBypassUser(page, 'pw-auth-access-token');
- await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/home/);
+ await expect
+ .poll(async () => page.evaluate(() => window.location.hash))
+ .toMatch(/^#\/(home|chat)/);
await expect(await waitForMockRequest('GET', '/auth/me')).toBeTruthy();
});
@@ -55,7 +57,9 @@ test.describe('Auth & Access Control', () => {
await signInViaBypassUser(page, 'pw-auth-access-second');
- await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/home/);
+ await expect
+ .poll(async () => page.evaluate(() => window.location.hash))
+ .toMatch(/^#\/(home|chat)/);
await expect
.poll(async () => {
const requests = await mockRequests();
diff --git a/app/test/playwright/specs/chat-conversation-history.spec.ts b/app/test/playwright/specs/chat-conversation-history.spec.ts
index c66ce8201..63d3514f0 100644
--- a/app/test/playwright/specs/chat-conversation-history.spec.ts
+++ b/app/test/playwright/specs/chat-conversation-history.spec.ts
@@ -134,9 +134,10 @@ test.describe('Chat Conversation History', () => {
await createNewThread(page);
await sendMessage(page, FIRST_PROMPT);
- await expect(
- page.locator('div.bg-stone-200').filter({ hasText: FIRST_RESPONSE }).first()
- ).toBeVisible({ timeout: 20_000 });
+ // The assistant bubble's Tailwind class carries an opacity modifier
+ // (`bg-stone-200/80`), which is a single class token — a plain `.bg-stone-200`
+ // selector can't match it. Assert on the rendered response text instead.
+ await expect(page.getByText(FIRST_RESPONSE).first()).toBeVisible({ timeout: 20_000 });
await resetMock();
await setMockBehavior(
diff --git a/app/test/playwright/specs/chat-management-functional.spec.ts b/app/test/playwright/specs/chat-management-functional.spec.ts
index 52a57dcd1..82207137e 100644
--- a/app/test/playwright/specs/chat-management-functional.spec.ts
+++ b/app/test/playwright/specs/chat-management-functional.spec.ts
@@ -65,9 +65,20 @@ async function openChat(page: Page, userId: string): Promise {
}
async function newThread(page: Page): Promise {
- await page.getByRole('button', { name: /^New$/ }).click();
- await expect.poll(() => selectedThreadId(page), { timeout: 10_000 }).not.toBeNull();
- return (await selectedThreadId(page))!;
+ // The sidebar "new thread" control now reads "New Conversation" (was "New"),
+ // so anchor on its stable testid rather than the accessible name.
+ //
+ // chat-as-home may already have a non-null selectedThreadId (an auto-created
+ // empty thread) before this click, so waiting only for "non-null" could
+ // return that stale id while the click-created thread is still racing in.
+ // Capture the prior id and wait for the selection to advance to the freshly
+ // created thread (handleCreateNewThread always creates a new unique id).
+ const before = await selectedThreadId(page);
+ await page.getByTestId('new-thread-button').click({ force: true });
+ await expect.poll(() => selectedThreadId(page), { timeout: 10_000 }).not.toBe(before);
+ const created = await selectedThreadId(page);
+ expect(created).not.toBeNull();
+ return created!;
}
test.describe('Chat management functional coverage', () => {
@@ -159,12 +170,19 @@ test.describe('Chat management functional coverage', () => {
const threadId = await newThread(page);
const title = `Playwright thread ${Date.now()}`;
+ // Inline rename from the conversation header (restored after #3751). The
+ // chat-as-home surface may auto-select a different empty thread, so assert
+ // on the unique renamed title appearing in the thread list rather than
+ // pinning to a specific row — that deterministically proves the header
+ // rename committed end-to-end. (Rename targeting is covered deterministically
+ // by the Conversations rename unit test.)
await page.getByRole('button', { name: 'Edit thread title' }).click({ force: true });
await page.getByRole('textbox', { name: 'Edit thread title' }).fill(title);
await page.keyboard.press('Enter');
- await expect(page.getByRole('heading', { name: title })).toBeVisible();
+ await expect(page.getByText(title).first()).toBeVisible({ timeout: 10_000 });
- await page.getByRole('button', { name: 'Show sidebar' }).click();
+ // Deletion remains available from the thread row in the chat sidebar, which
+ // is visible by default on the /chat surface.
await page
.getByTestId(`thread-row-${threadId}`)
.getByTitle('Delete thread')
diff --git a/app/test/playwright/specs/core-port-conflict-recovery.spec.ts b/app/test/playwright/specs/core-port-conflict-recovery.spec.ts
index 05f39acb7..3b03a164f 100644
--- a/app/test/playwright/specs/core-port-conflict-recovery.spec.ts
+++ b/app/test/playwright/specs/core-port-conflict-recovery.spec.ts
@@ -12,8 +12,8 @@ test.describe('Core port conflict recovery', () => {
await waitForAppReady(page);
const text = await page.locator('#root').innerText();
expect(
- ['Ask your assistant anything', 'Your device is connected', 'Welcome', 'Get Started'].some(
- marker => text.includes(marker)
+ ['New Conversation', 'Threads', 'Welcome', 'Get Started'].some(marker =>
+ text.includes(marker)
)
).toBe(true);
});
diff --git a/app/test/playwright/specs/cron-jobs-flow.spec.ts b/app/test/playwright/specs/cron-jobs-flow.spec.ts
index 62b320812..ec3cb31a4 100644
--- a/app/test/playwright/specs/cron-jobs-flow.spec.ts
+++ b/app/test/playwright/specs/cron-jobs-flow.spec.ts
@@ -18,14 +18,11 @@ test.describe('Cron jobs settings panel', () => {
await bootAuthenticatedPage(page, 'pw-cron-jobs-flow', '/home');
});
- test('home screen is reachable after login', async ({ page }) => {
+ test('chat surface is reachable after login', async ({ page }) => {
+ // Home folded into the unified chat surface: post-login landing is /chat.
await waitForAppReady(page);
const text = await page.locator('#root').innerText();
- expect(
- ['Ask your assistant anything', 'Your device is connected'].some(marker =>
- text.includes(marker)
- )
- ).toBe(true);
+ expect(['New Conversation', 'Threads'].some(marker => text.includes(marker))).toBe(true);
});
test('cron jobs panel renders in the browser lane and surfaces the current fallback state', async ({
diff --git a/app/test/playwright/specs/linux-cef-deb-runtime.spec.ts b/app/test/playwright/specs/linux-cef-deb-runtime.spec.ts
index 867ab3983..432d504e0 100644
--- a/app/test/playwright/specs/linux-cef-deb-runtime.spec.ts
+++ b/app/test/playwright/specs/linux-cef-deb-runtime.spec.ts
@@ -21,11 +21,9 @@ test.describe('Linux CEF deb package runtime', () => {
test('main web shell is created and visible', async ({ page }) => {
await waitForAppReady(page);
const text = await page.locator('#root').innerText();
- expect(
- ['Ask your assistant anything', 'Your device is connected', 'Home', 'Chat'].some(marker =>
- text.includes(marker)
- )
- ).toBe(true);
+ expect(['New Conversation', 'Threads', 'Chat'].some(marker => text.includes(marker))).toBe(
+ true
+ );
});
test.skip('native core_rpc_url / tray / CEF packaging assertions are desktop-only', async () => {});
diff --git a/app/test/playwright/specs/login-flow.spec.ts b/app/test/playwright/specs/login-flow.spec.ts
index a31443d6e..c2b0015a1 100644
--- a/app/test/playwright/specs/login-flow.spec.ts
+++ b/app/test/playwright/specs/login-flow.spec.ts
@@ -50,14 +50,18 @@ test.describe('Login Flow', () => {
test('callback login consumes the mock login token and lands on home', async ({ page }) => {
await signInViaCallbackToken(page, 'playwright-login-token');
- await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/home/);
+ await expect
+ .poll(async () => page.evaluate(() => window.location.hash))
+ .toMatch(/^#\/(home|chat)/);
await expect(await waitForMockRequest('GET', '/auth/me')).toBeTruthy();
});
test('bypass login skips token consume and still lands on home', async ({ page }) => {
await signInViaBypassUser(page, 'playwright-bypass-user');
- await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/home/);
+ await expect
+ .poll(async () => page.evaluate(() => window.location.hash))
+ .toMatch(/^#\/(home|chat)/);
const consumeCall = (await requests()).find(
request => request.method === 'POST' && request.url.includes('/telegram/login-tokens/')
diff --git a/app/test/playwright/specs/logout-relogin-onboarding.spec.ts b/app/test/playwright/specs/logout-relogin-onboarding.spec.ts
index cd64b01c7..50d3feb43 100644
--- a/app/test/playwright/specs/logout-relogin-onboarding.spec.ts
+++ b/app/test/playwright/specs/logout-relogin-onboarding.spec.ts
@@ -48,7 +48,7 @@ async function completeCloudOnboarding(page: Page): Promise {
await clickOnboardingNext(page);
const reachedHome = await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 15_000 })
- .toMatch(/^#\/home/)
+ .toMatch(/^#\/(home|chat)/)
.then(
() => true,
() => false
diff --git a/app/test/playwright/specs/mcp-tab-flow.spec.ts b/app/test/playwright/specs/mcp-tab-flow.spec.ts
index 729815019..64476247d 100644
--- a/app/test/playwright/specs/mcp-tab-flow.spec.ts
+++ b/app/test/playwright/specs/mcp-tab-flow.spec.ts
@@ -478,8 +478,10 @@ test.describe('MCP Tab — Empty & Edge States', () => {
await navigateToMcpTab(page);
await page.getByRole('button', { name: /Installed/ }).click();
- const emptyMsg = page.locator('text=/no.*servers|no.*installed/i');
- await expect(emptyMsg).toBeVisible({ timeout: 5_000 });
+ // Target the empty-state element directly: a broad `text=/no.*servers/i`
+ // locator also matches ancestor containers (the root shell wraps the panel),
+ // tripping Playwright strict mode.
+ await expect(page.getByTestId('mcp-installed-empty')).toBeVisible({ timeout: 10_000 });
});
test('search with no results shows no-results message', async ({ page }) => {
@@ -504,8 +506,8 @@ test.describe('MCP Tab — Empty & Edge States', () => {
await navigateToMcpTab(page);
await page.locator('input[type="search"]').fill('xyznonexistent999');
- // Wait for the no-results state to appear rather than using a fixed delay
- const noResults = page.locator('text=/no.*results|no.*found|no.*servers/i');
- await expect(noResults).toBeVisible({ timeout: 5_000 });
+ // Target the catalog empty-state element directly — a broad text regex also
+ // matches the root-shell ancestor container and trips strict mode.
+ await expect(page.getByTestId('mcp-catalog-empty')).toBeVisible({ timeout: 10_000 });
});
});
diff --git a/app/test/playwright/specs/navigation-smoothness.spec.ts b/app/test/playwright/specs/navigation-smoothness.spec.ts
index de7f3e2ea..042bb8eff 100644
--- a/app/test/playwright/specs/navigation-smoothness.spec.ts
+++ b/app/test/playwright/specs/navigation-smoothness.spec.ts
@@ -10,13 +10,15 @@ interface RouteCheck {
const routes: RouteCheck[] = [
{ hash: '/chat', markers: ['Threads', 'Chat', 'Message', 'New'] },
{ hash: '/connections', markers: ['Composio', 'Channels', 'MCP Servers', 'Skills'] },
- { hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected'] },
+ // Home folded into the unified chat surface — /home redirects to /chat.
+ { hash: '/home', markers: ['New Conversation'] },
{ hash: '/channels', markers: ['Channels', 'Connections', 'Telegram', 'Discord'] },
{ hash: '/notifications', markers: ['Notifications', 'Alerts', 'No alerts yet'] },
{ hash: '/rewards', markers: ['Rewards', 'Referral', 'Credits', 'Invite'] },
{ hash: '/settings', markers: ['Settings', 'Account', 'Billing', 'Advanced'] },
{ hash: '/settings/notifications-hub', markers: ['Notifications'] },
- { hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected'] },
+ // Home folded into the unified chat surface — /home redirects to /chat.
+ { hash: '/home', markers: ['New Conversation'] },
];
async function rootTextLength(page: import('@playwright/test').Page): Promise {
@@ -53,11 +55,13 @@ test.describe('Navigation Smoothness', () => {
}
});
- test('final state is /home with correct content', async ({ page }) => {
+ test('final state is the chat surface with correct content', async ({ page }) => {
+ // Home folded into the unified chat surface: /home redirects to /chat and
+ // the chat "new window" empty state renders the former Home hero card.
await page.goto('/#/home');
await waitForAppReady(page);
- await expect(page.getByRole('button', { name: /Ask your assistant anything/i })).toBeVisible();
- await expect(page.getByText(/Your device is connected/i)).toBeVisible();
- await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/home/);
+ await expect(page.locator('[data-walkthrough="home-card"]')).toBeVisible();
+ await expect(page.getByText('New Conversation')).toBeVisible();
+ await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/chat/);
});
});
diff --git a/app/test/playwright/specs/navigation.spec.ts b/app/test/playwright/specs/navigation.spec.ts
index 460616942..51818b819 100644
--- a/app/test/playwright/specs/navigation.spec.ts
+++ b/app/test/playwright/specs/navigation.spec.ts
@@ -14,8 +14,9 @@ interface RouteEntry {
// /skills → /connections (Phase 2)
// /activity → /settings/notifications (Phase 6)
// /intelligence → /settings/notifications (Phase 6)
+// /home → /chat (Home folded into the unified two-panel chat surface)
const ROUTES: RouteEntry[] = [
- { route: '/home' },
+ { route: '/home', expectedHash: '/chat' }, // back-compat redirect (Home → chat)
{ route: '/human' }, // first-class route again (no longer redirects to /chat)
{ route: '/chat' },
{ route: '/connections' },
diff --git a/app/test/playwright/specs/onboarding-modes.spec.ts b/app/test/playwright/specs/onboarding-modes.spec.ts
index 608c00b66..8eb04deab 100644
--- a/app/test/playwright/specs/onboarding-modes.spec.ts
+++ b/app/test/playwright/specs/onboarding-modes.spec.ts
@@ -61,7 +61,7 @@ async function expectOnboardingCompleted(): Promise {
async function ensureHomeOrForceComplete(page: Page): Promise {
const reachedHome = await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 20_000 })
- .toMatch(/^#\/home/)
+ .toMatch(/^#\/(home|chat)/)
.then(
() => true,
() => false
diff --git a/app/test/playwright/specs/runtime-picker-login.spec.ts b/app/test/playwright/specs/runtime-picker-login.spec.ts
index a994ce6a6..9fda33b7e 100644
--- a/app/test/playwright/specs/runtime-picker-login.spec.ts
+++ b/app/test/playwright/specs/runtime-picker-login.spec.ts
@@ -110,7 +110,9 @@ test.describe('Runtime picker -> login -> logout', () => {
await signInViaBypassUser(page, 'pw-runtime-picker-login');
await dismissWalkthroughIfPresent(page);
- await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/home/);
+ await expect
+ .poll(async () => page.evaluate(() => window.location.hash))
+ .toMatch(/^#\/(home|chat)/);
await expect(await waitForMockRequest('GET', '/auth/me')).toBeTruthy();
await page.goto('/#/settings/account');
diff --git a/app/test/playwright/specs/skill-execution-flow.spec.ts b/app/test/playwright/specs/skill-execution-flow.spec.ts
index 92ab3bafd..690b82829 100644
--- a/app/test/playwright/specs/skill-execution-flow.spec.ts
+++ b/app/test/playwright/specs/skill-execution-flow.spec.ts
@@ -11,11 +11,7 @@ test.describe('Skill discovery (UI + core RPC)', () => {
test('lands the user on a logged-in shell', async ({ page }) => {
await waitForAppReady(page);
const text = await page.locator('#root').innerText();
- expect(
- ['Ask your assistant anything', 'Your device is connected'].some(marker =>
- text.includes(marker)
- )
- ).toBe(true);
+ expect(['New Conversation', 'Threads'].some(marker => text.includes(marker))).toBe(true);
});
test('core.ping responds over the same JSON-RPC URL the UI uses', async () => {
diff --git a/app/test/playwright/specs/skill-socket-reconnect.spec.ts b/app/test/playwright/specs/skill-socket-reconnect.spec.ts
index bd3ce9039..3c0ee70ef 100644
--- a/app/test/playwright/specs/skill-socket-reconnect.spec.ts
+++ b/app/test/playwright/specs/skill-socket-reconnect.spec.ts
@@ -7,12 +7,14 @@ import {
} from '../helpers/core-rpc';
test.describe('Socket reconnect skill sync smoke', () => {
- test('reaches Home after login as baseline for post-reconnect flows', async ({ page }) => {
+ test('reaches the chat surface after login as baseline for post-reconnect flows', async ({
+ page,
+ }) => {
+ // Home folded into the unified chat surface: post-login landing is /chat,
+ // whose "new window" empty state renders the former Home hero card.
await bootAuthenticatedPage(page, 'pw-skill-socket-reconnect', '/home');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
- await expect(
- page.getByRole('button', { name: 'Ask your assistant anything...' })
- ).toBeVisible();
+ await expect(page.locator('[data-walkthrough="home-card"]')).toBeVisible();
});
});
diff --git a/app/test/playwright/specs/tauri-commands.spec.ts b/app/test/playwright/specs/tauri-commands.spec.ts
index f537be3f7..13299fde2 100644
--- a/app/test/playwright/specs/tauri-commands.spec.ts
+++ b/app/test/playwright/specs/tauri-commands.spec.ts
@@ -11,11 +11,9 @@ test.describe('Tauri commands', () => {
test('app chrome is visible', async ({ page }) => {
await waitForAppReady(page);
const text = await page.locator('#root').innerText();
- expect(
- ['Ask your assistant anything', 'Your device is connected', 'Home', 'Chat'].some(marker =>
- text.includes(marker)
- )
- ).toBe(true);
+ expect(['New Conversation', 'Threads', 'Chat'].some(marker => text.includes(marker))).toBe(
+ true
+ );
});
test('browser lane exposes the core RPC URL and token bootstrap values', async ({ page }) => {
diff --git a/app/test/playwright/specs/top-level-functional-flows.spec.ts b/app/test/playwright/specs/top-level-functional-flows.spec.ts
index a119761ac..91ff31275 100644
--- a/app/test/playwright/specs/top-level-functional-flows.spec.ts
+++ b/app/test/playwright/specs/top-level-functional-flows.spec.ts
@@ -94,9 +94,10 @@ test.describe('Top-level functional flows', () => {
test('major top-level pages render actionable UI without blanking', async ({ page }) => {
await bootAuthenticatedPage(page, 'pw-top-level-ui', '/home');
const routes: Array<[string, RegExp]> = [
- ['/home', /Ask your assistant anything|Start/],
+ // Home folded into the unified chat surface — /home redirects to /chat.
+ ['/home', /New Conversation|Threads/],
['/connections', /Composio Integrations|Composio|Channels|MCP Servers/],
- ['/chat', /How can I help you today|No messages yet|Threads/],
+ ['/chat', /New Conversation|No messages yet|Threads/],
['/settings/notifications-hub', /Notifications/],
['/notifications', /Notifications|System Events/],
['/rewards', /Rewards|Referrals|Redeem/],
diff --git a/app/test/playwright/specs/user-journey-full-task.spec.ts b/app/test/playwright/specs/user-journey-full-task.spec.ts
index ea1e9eb25..7453184bb 100644
--- a/app/test/playwright/specs/user-journey-full-task.spec.ts
+++ b/app/test/playwright/specs/user-journey-full-task.spec.ts
@@ -137,14 +137,21 @@ test.describe('User journey - full research task', () => {
await sendMessage(page, PROMPT);
await expect(page.getByText(CANARY_FINAL).first()).toBeVisible({ timeout: 45_000 });
+ // Navigate away and back to confirm the thread (and its messages) persist.
+ // Home folded into the unified chat surface, so /home now redirects to
+ // /chat — the landing hash settles on /chat.
await page.goto('/#/home');
await waitForAppReady(page);
await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 })
- .toContain('/home');
+ .toContain('/chat');
+ // The chat-as-home surface lands on its "new window" hero rather than
+ // re-opening the last thread, so re-select the thread from the sidebar to
+ // confirm its messages persisted across navigation.
await page.goto('/#/chat');
await waitForAppReady(page);
+ await page.getByTestId(`thread-row-${threadId}`).click({ force: true });
await expect(page.getByText(CANARY_FINAL).first()).toBeVisible({ timeout: 15_000 });
});
});
diff --git a/app/test/playwright/specs/user-journey-settings-round-trip.spec.ts b/app/test/playwright/specs/user-journey-settings-round-trip.spec.ts
index a36c7208d..282bc91df 100644
--- a/app/test/playwright/specs/user-journey-settings-round-trip.spec.ts
+++ b/app/test/playwright/specs/user-journey-settings-round-trip.spec.ts
@@ -17,9 +17,10 @@ const panels: PanelCheck[] = [
hash: '/settings/billing',
markers: ['Billing moved to the web', 'Open billing dashboard', 'credits'],
},
- { hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected'] },
+ // Home folded into the unified chat surface — /home redirects to /chat.
+ { hash: '/home', markers: ['New Conversation'] },
// /chat is the Assistant surface (thread list + agent chat header).
- { hash: '/chat', markers: ['Threads', 'New thread', 'Talk to Tiny', 'Reasoning'] },
+ { hash: '/chat', markers: ['New Conversation', 'Threads', 'New thread', 'Reasoning'] },
];
async function waitForPanelLoad(page: Parameters[0]['page']) {
@@ -34,17 +35,14 @@ test.describe('User journey - settings round-trip', () => {
await bootAuthenticatedPage(page, 'pw-settings-round-trip-' + testSlug, '/home');
});
- test('starts on /home after login', async ({ page }) => {
+ test('starts on the chat surface after login', async ({ page }) => {
+ // Home folded into the unified chat surface: post-login landing is /chat.
await waitForAppReady(page);
await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: PANEL_TIMEOUT })
- .toMatch(/^#\/home/);
+ .toMatch(/^#\/chat/);
const text = await page.locator('#root').innerText();
- expect(
- ['Ask your assistant anything', 'Your device is connected'].some(marker =>
- text.includes(marker)
- )
- ).toBe(true);
+ expect(['New Conversation', 'Threads'].some(marker => text.includes(marker))).toBe(true);
});
for (const panel of panels) {
diff --git a/app/test/playwright/specs/webhooks-ingress-flow.spec.ts b/app/test/playwright/specs/webhooks-ingress-flow.spec.ts
index fe267e024..f68b9ccc0 100644
--- a/app/test/playwright/specs/webhooks-ingress-flow.spec.ts
+++ b/app/test/playwright/specs/webhooks-ingress-flow.spec.ts
@@ -11,11 +11,7 @@ test.describe('Webhooks ingress surface (stub-level)', () => {
test('reaches the app shell after onboarding', async ({ page }) => {
await waitForAppReady(page);
const text = await page.locator('#root').innerText();
- expect(
- ['Ask your assistant anything', 'Your device is connected'].some(marker =>
- text.includes(marker)
- )
- ).toBe(true);
+ expect(['New Conversation', 'Threads'].some(marker => text.includes(marker))).toBe(true);
});
test('exposes the stub webhook RPC surface with stable result and log shapes', async () => {
diff --git a/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts b/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts
index 31c2833a4..0b70a5164 100644
--- a/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts
+++ b/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts
@@ -63,11 +63,7 @@ test.describe('Webhook tunnel CRUD (UI + core RPC + mock backend)', () => {
test('reached the logged-in shell after onboarding', async ({ page }) => {
await waitForAppReady(page);
const text = await page.locator('#root').innerText();
- expect(
- ['Ask your assistant anything', 'Your device is connected'].some(marker =>
- text.includes(marker)
- )
- ).toBe(true);
+ expect(['New Conversation', 'Threads'].some(marker => text.includes(marker))).toBe(true);
});
test('creates a tunnel, lists it, deletes it, and matches mock-backend traffic', async () => {
diff --git a/src/openhuman/workflows/ops_create.rs b/src/openhuman/workflows/ops_create.rs
index 813c9b51a..b6fd90500 100644
--- a/src/openhuman/workflows/ops_create.rs
+++ b/src/openhuman/workflows/ops_create.rs
@@ -707,8 +707,12 @@ mod render_skill_toml_tests {
let mut saw = false;
loop {
match rx.try_recv() {
- Ok(DomainEvent::WorkflowsChanged { reason }) => {
- assert_eq!(reason, "create");
+ // The event bus is a process-wide singleton, so other tests
+ // running in parallel publish their own WorkflowsChanged events
+ // (e.g. "install"/"uninstall" from ops_install). Match only our
+ // own "create" reason and skip the rest rather than asserting on
+ // whichever event happens to arrive first.
+ Ok(DomainEvent::WorkflowsChanged { reason }) if reason == "create" => {
saw = true;
break;
}