Skip to content

Commit 051c6ea

Browse files
authored
Merge pull request #20 from redis-applied-ai/fix/new-chat-while-chat-is-running
UI fix: clicking New Chat while triage is running does not open a new chat
2 parents e2a2ca9 + a5d3d8e commit 051c6ea

File tree

5 files changed

+113
-50
lines changed

5 files changed

+113
-50
lines changed

Dockerfile

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,22 +65,23 @@ RUN apt-get update && apt-get install -y \
6565
&& apt-get clean \
6666
&& rm -rf /var/lib/apt/lists/*
6767

68-
# Copy the virtual environment from the builder stage
69-
COPY --from=builder /app/.venv /app/.venv
70-
COPY --from=builder /app/artifacts /app/artifacts
71-
COPY --from=builder /app /app
68+
# Create the application user and docker group before copying app files so we
69+
# can set ownership in a single COPY layer instead of a separate chown layer.
70+
RUN useradd --create-home --shell /bin/bash app && \
71+
(groupadd -g 999 docker || true) && \
72+
usermod -aG docker app
73+
74+
# Copy the application and virtual environment from the builder stage with
75+
# correct ownership. This avoids an extra chown -R layer over /app.
76+
COPY --from=builder --chown=app:app /app /app
7277

7378
# Add the virtual environment to PATH
7479
# This allows us to run "uvicorn" or "python" directly without "uv run"
7580
ENV PATH="/app/.venv/bin:$PATH"
7681

77-
# Setup permissions
82+
# Install the entrypoint script
7883
COPY scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
79-
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && \
80-
useradd --create-home --shell /bin/bash app && \
81-
groupadd -g 999 docker || true && \
82-
usermod -aG docker app && \
83-
chown -R app:app /app
84+
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
8485

8586
USER app
8687

ui/e2e/chat.spec.ts

Lines changed: 75 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,79 @@ import { test, expect } from '@playwright/test';
66
const uniqueMessage = `E2E hello ${Date.now()}`;
77

88
test('send a message on Triage and see live processing', async ({ page }) => {
9-
let threadId: string | undefined;
10-
await page.goto('/triage');
11-
12-
// New-conversation textarea (initial input)
13-
const newConversationTextarea = page.getByPlaceholder('Describe your Redis issue or ask a question...');
14-
await newConversationTextarea.waitFor({ state: 'visible' });
15-
16-
// Type and send
17-
await newConversationTextarea.fill(uniqueMessage);
18-
await page.getByRole('button', { name: 'Send' }).click();
19-
20-
// Capture created thread id
21-
const resp = await page.waitForResponse((r) => r.url().includes('/api/v1/tasks') && r.request().method() === 'POST');
22-
const data = await resp.json();
23-
threadId = data.thread_id as string;
24-
25-
try {
26-
// After sending, UI enters a busy state and shows a Stop button while the task runs.
27-
await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible({ timeout: 30_000 });
28-
29-
// The user's message should appear in the transcript area soon after sending.
30-
// The message may appear both in the sidebar and in the chat bubble; assert the chat bubble copy.
31-
await expect(page.getByText(uniqueMessage).last()).toBeVisible({ timeout: 30_000 });
32-
33-
// Optional: stop the task to end the live run and return to transcript view.
34-
await page.getByRole('button', { name: 'Stop' }).click();
35-
await expect(page.getByRole('button', { name: 'Stop' })).toBeHidden({ timeout: 30_000 });
36-
} finally {
37-
if (threadId) {
38-
await page.request.delete(`/api/v1/threads/${threadId}`);
39-
}
40-
}
9+
let threadId: string | undefined;
10+
await page.goto('/triage');
11+
12+
// New-conversation textarea (initial input)
13+
const newConversationTextarea = page.getByPlaceholder('Describe your Redis issue or ask a question...');
14+
await newConversationTextarea.waitFor({ state: 'visible' });
15+
16+
// Type and send
17+
await newConversationTextarea.fill(uniqueMessage);
18+
await page.getByRole('button', { name: 'Send' }).click();
19+
20+
// Capture created thread id
21+
const resp = await page.waitForResponse((r) => r.url().includes('/api/v1/tasks') && r.request().method() === 'POST');
22+
const data = await resp.json();
23+
threadId = data.thread_id as string;
24+
25+
try {
26+
// After sending, UI enters a busy state and shows a Stop button while the task runs.
27+
await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible({ timeout: 30_000 });
28+
29+
// The user's message should appear in the transcript area soon after sending.
30+
// The message may appear both in the sidebar and in the chat bubble; assert the chat bubble copy.
31+
await expect(page.getByText(uniqueMessage).last()).toBeVisible({ timeout: 30_000 });
32+
33+
// Optional: stop the task to end the live run and return to transcript view.
34+
await page.getByRole('button', { name: 'Stop' }).click();
35+
await expect(page.getByRole('button', { name: 'Stop' })).toBeHidden({ timeout: 30_000 });
36+
} finally {
37+
if (threadId) {
38+
await page.request.delete(`/api/v1/threads/${threadId}`);
39+
}
40+
}
41+
});
42+
43+
test('New Chat from an existing thread shows the new conversation form', async ({ page }) => {
44+
let threadId: string | undefined;
45+
await page.goto('/triage');
46+
47+
// Start a new conversation to create a thread
48+
const newConversationTextarea = page.getByPlaceholder('Describe your Redis issue or ask a question...');
49+
await newConversationTextarea.waitFor({ state: 'visible' });
50+
await newConversationTextarea.fill(uniqueMessage);
51+
await page.getByRole('button', { name: 'Send' }).click();
52+
53+
// Capture created thread id
54+
const resp = await page.waitForResponse((r) => r.url().includes('/api/v1/tasks') && r.request().method() === 'POST');
55+
const data = await resp.json();
56+
threadId = data.thread_id as string;
57+
58+
try {
59+
// Navigate to Triage with the thread query parameter as other pages do
60+
await page.goto(`/triage?thread=${threadId}`);
61+
62+
// Existing-thread composer should be visible
63+
const continueTextarea = page.getByPlaceholder('Continue the conversation...');
64+
await expect(continueTextarea).toBeVisible({ timeout: 30_000 });
65+
66+
// Click New Chat in the Conversations sidebar
67+
await page.getByRole('button', { name: 'New Chat' }).click();
68+
69+
// After New Chat, we should see the new-conversation form textarea
70+
const newChatTextarea = page.getByPlaceholder('Describe your Redis issue or ask a question...');
71+
await expect(newChatTextarea).toBeVisible({ timeout: 30_000 });
72+
73+
// And the existing-thread composer should no longer be present
74+
await expect(continueTextarea).toHaveCount(0);
75+
76+
// URL should no longer contain the thread query parameter
77+
const url = page.url();
78+
expect(url).not.toContain('thread=');
79+
} finally {
80+
if (threadId) {
81+
await page.request.delete(`/api/v1/threads/${threadId}`);
82+
}
83+
}
4184
});

ui/playwright.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default defineConfig({
88
globalSetup: './e2e/support/global-setup.mjs',
99
globalTeardown: './e2e/support/global-teardown.mjs',
1010
use: {
11-
baseURL: 'http://localhost:3000',
11+
baseURL: 'http://localhost:3002',
1212
trace: 'on-first-retry',
1313
video: 'retain-on-failure',
1414
screenshot: 'only-on-failure',

ui/src/pages/Triage.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ interface ChatThread {
6161
}
6262

6363
const Triage = () => {
64-
const [searchParams] = useSearchParams();
64+
const [searchParams, setSearchParams] = useSearchParams();
6565
const [threads, setThreads] = useState<ChatThread[]>([]);
6666
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
6767
const [messages, setMessages] = useState<ChatMessage[]>([]);
@@ -131,14 +131,23 @@ const Triage = () => {
131131
// Handle URL parameters to auto-select thread
132132
useEffect(() => {
133133
const threadParam = searchParams.get("thread");
134-
if (threadParam && threads.length > 0 && !activeThreadId) {
134+
if (
135+
threadParam &&
136+
threads.length > 0 &&
137+
!activeThreadId &&
138+
// If the user has explicitly chosen to start a new chat, do not
139+
// auto-select a thread from the URL. This prevents the `?thread=`
140+
// query param from re-activating an old conversation after clicking
141+
// the "New Chat" button.
142+
!showNewConversation
143+
) {
135144
// Check if the thread exists in our loaded threads
136145
const threadExists = threads.some((thread) => thread.id === threadParam);
137146
if (threadExists) {
138147
selectThread(threadParam);
139148
}
140149
}
141-
}, [threads, searchParams, activeThreadId]);
150+
}, [threads, searchParams, activeThreadId, showNewConversation]);
142151

143152
// Auto-show new conversation when landing on the page or when threads exist but none selected
144153
useEffect(() => {
@@ -417,6 +426,20 @@ const Triage = () => {
417426
setShowNewConversation(true);
418427
setShowWebSocketMonitor(false);
419428

429+
// Clear any `thread` query parameter from the URL so that the page
430+
// no longer treats an existing thread as selected. Without this, the
431+
// URL effect above can immediately re-select the old thread after we
432+
// clear `activeThreadId`, causing the UI to stay in "continue
433+
// conversation" mode instead of showing the new chat form.
434+
try {
435+
const params = new URLSearchParams(searchParams);
436+
params.delete("thread");
437+
setSearchParams(params);
438+
} catch {
439+
// If anything goes wrong manipulating search params, fail soft and
440+
// continue showing the new chat UI based on component state.
441+
}
442+
420443
// On mobile only, switch to chat view
421444
if (window.innerWidth < 768) {
422445
// md breakpoint

ui/test-results/.last-run.json

Lines changed: 0 additions & 4 deletions
This file was deleted.

0 commit comments

Comments
 (0)