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
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.031"
VERSION = "0.261.032"
IS_DEVELOPMENT = is_development_env_enabled()

SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
Expand Down
7 changes: 6 additions & 1 deletion application/v2_ui/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { useUiStore } from '../../stores/uiStore';
import { useBootstrapStore } from '../../stores/bootstrapStore';
import { useChatStore } from '../../stores/chatStore';
import { classicChatHref } from '../../lib/conversationUrl';
import { ConversationRail } from '../chat/ConversationRail';

interface NavItem {
Expand Down Expand Up @@ -78,6 +79,7 @@ function BrandMark({ collapsed }: { collapsed: boolean }) {

function UserMenu({ collapsed }: { collapsed: boolean }) {
const user = useBootstrapStore((state) => state.data?.user);
const activeConversationId = useChatStore((state) => state.activeConversationId);
const [open, setOpen] = useState(false);

const initials =
Expand Down Expand Up @@ -105,8 +107,11 @@ function UserMenu({ collapsed }: { collapsed: boolean }) {
>
<User size={15} /> Profile
</a>
{/* Carries the open conversation across, since both interfaces read the
same parameter. Crossing over otherwise lands on the conversation
list, leaving you to find your place again. */}
<a
href="/chats"
href={classicChatHref(activeConversationId)}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-1 hover:bg-surface-2"
>
<ChevronLeft size={15} /> Back to classic UI
Expand Down
80 changes: 80 additions & 0 deletions application/v2_ui/src/lib/conversationUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// conversationUrl.ts
// Reading and writing the conversation that a URL names.
//
// The classic interface puts the open conversation in the address bar so it can be copied
// and shared, and reads it back on load. These helpers hold the same rules for the V2 SPA
// in one place, away from React, so the direction of travel stays explicit: a URL is read
// once when the chat page first renders, and written whenever the open conversation
// changes.

/** The spelling this interface writes. */
export const CONVERSATION_PARAM = 'conversationId';

/**
* Also accepted when reading, never written.
*
* The server emits both spellings and they are already in circulation: notifications and
* workflow runs build `/chats?conversationId=`, while chat responses and workspace document
* rows build `/chats?conversation_id=`. The classic client accepts either, so a link that
* works there must work here too.
*/
export const LEGACY_CONVERSATION_PARAM = 'conversation_id';

/** Where the classic interface serves the chat page. */
const CLASSIC_CHAT_PATH = '/chats';

/**
* The conversation a set of query parameters names, or null when it names none.
*
* The canonical spelling wins when both are present, so normalising a legacy link cannot
* change which conversation it opens.
*/
export function readConversationParam(params: URLSearchParams): string | null {
const value =
params.get(CONVERSATION_PARAM) ?? params.get(LEGACY_CONVERSATION_PARAM) ?? '';
const trimmed = value.trim();
return trimmed || null;
}

/**
* The query parameters a URL should carry for `conversationId`, or null when it already
* carries exactly that.
*
* The null return is doing real work: it is what keeps the effect that writes the URL from
* re-entering itself, and it means leaving and returning to the chat page costs no
* navigation. A legacy parameter always counts as a difference, so an incoming
* `?conversation_id=` link is rewritten to the canonical spelling on arrival.
*/
export function syncedConversationParams(
params: URLSearchParams,
conversationId: string | null,
): URLSearchParams | null {
const current = params.get(CONVERSATION_PARAM);
const hasLegacy = params.has(LEGACY_CONVERSATION_PARAM);

if (!hasLegacy && (current ?? null) === conversationId) {
return null;
}

const next = new URLSearchParams(params);
next.delete(LEGACY_CONVERSATION_PARAM);
if (conversationId) {
next.set(CONVERSATION_PARAM, conversationId);
} else {
next.delete(CONVERSATION_PARAM);
}
return next;
}

/**
* A link to the classic chat page, carrying the open conversation when there is one.
*
* Crossing between the two interfaces otherwise lands on the conversation list, which
* means finding your place again in a rail that may be paged.
*/
export function classicChatHref(conversationId: string | null): string {
if (!conversationId) {
return CLASSIC_CHAT_PATH;
}
return `${CLASSIC_CHAT_PATH}?${CONVERSATION_PARAM}=${encodeURIComponent(conversationId)}`;
}
72 changes: 71 additions & 1 deletion application/v2_ui/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,86 @@
// ChatPage.tsx
// Chat surface: header, message thread, composer, and the right-hand drawer.

import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { clsx } from 'clsx';
import { Files, Info, ListOrdered, Maximize2, Minimize2 } from 'lucide-react';
import { useChatStore } from '../stores/chatStore';
import { useBootstrapStore } from '../stores/bootstrapStore';
import { useUiStore } from '../stores/uiStore';
import { readConversationParam, syncedConversationParams } from '../lib/conversationUrl';
import { MessageList } from '../components/chat/MessageList';
import { Composer } from '../components/chat/Composer';
import { ConversationDrawer } from '../components/chat/ConversationDrawer';
import { ConversationDetails } from '../components/chat/ConversationDetails';
import { ConversationBadges } from '../components/chat/ConversationBadges';

/**
* Keep the address bar and the open conversation describing each other.
*
* The two directions are not symmetrical, and that asymmetry is the whole design. The URL
* is read exactly once, when the page first renders, and written on every change of the
* open conversation after that. Reading continuously would fight the writing.
*
* The incoming id is captured in a lazy `useState` initialiser rather than inside an effect
* because effect order would otherwise decide the outcome: the effect that writes the URL
* also runs on mount, and with nothing open yet it would strip the parameter before the
* effect that reads it ever ran. A lazy initialiser runs during the first render, before
* any effect, so the link cannot be lost that way.
*/
function useConversationUrlSync() {
const [searchParams, setSearchParams] = useSearchParams();
const activeConversationId = useChatStore((state) => state.activeConversationId);
const openLinkedConversation = useChatStore((state) => state.openLinkedConversation);

const [linkedConversationId] = useState(() => readConversationParam(searchParams));
// Two flags with two jobs. The ref makes opening the link happen exactly once: React's
// StrictMode runs effects twice on mount, and a state flag is still false in the second
// invocation's closure, so it would open the conversation — and refetch its messages —
// twice. The state flag is what the write effect waits on, and has to be state because
// that effect must re-run once the link has been dealt with.
const linkConsumed = useRef(false);
const [linkHandled, setLinkHandled] = useState(!linkedConversationId);

useEffect(() => {
if (linkConsumed.current || !linkedConversationId) {
return;
}
linkConsumed.current = true;

// Read from the store rather than the subscribed value: leaving the chat page and
// coming back re-runs this with a conversation already open, and re-opening it
// would throw away a running stream for no reason.
if (linkedConversationId === useChatStore.getState().activeConversationId) {
setLinkHandled(true);
return;
}

// Released only once the open has settled, either way. Releasing it up front would
// let the write effect run during the moment before the conversation is open, see
// nothing open, and strip the very parameter that named it.
void openLinkedConversation(linkedConversationId).finally(() => setLinkHandled(true));
}, [linkedConversationId, openLinkedConversation]);

useEffect(() => {
// Held back until the link has been consumed, so the parameter survives long enough
// to be read.
if (!linkHandled) {
return;
}

const next = syncedConversationParams(searchParams, activeConversationId);
if (!next) {
return;
}

// `replace` rather than a new entry, matching the classic interface's
// `history.replaceState`: the address bar should describe what is open, not turn the
// back button into a list of every conversation visited.
setSearchParams(next, { replace: true });
}, [activeConversationId, linkHandled, searchParams, setSearchParams]);
}

function ChatHeader({ onOpenDetails }: { onOpenDetails: () => void }) {
const { activeConversationId, conversations, drawerMode, setDrawerMode, metadata } =
useChatStore();
Expand Down Expand Up @@ -135,6 +203,8 @@ function ChatHeader({ onOpenDetails }: { onOpenDetails: () => void }) {
export function ChatPage() {
const [detailsOpen, setDetailsOpen] = useState(false);

useConversationUrlSync();

return (
<div className="flex min-h-0 flex-1">
<div className="flex min-w-0 flex-1 flex-col">
Expand Down
86 changes: 85 additions & 1 deletion application/v2_ui/src/stores/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ interface ChatState {
setSearchTerm: (term: string) => void;

selectConversation: (conversationId: string | null) => Promise<void>;
/**
* Open a conversation named by the URL rather than clicked in the rail.
*
* Kept apart from `selectConversation` because the two have different failure
* modes. A row the user clicked is one the server just sent; a link can name a
* conversation that has since been deleted, or that belongs to somebody else.
*/
openLinkedConversation: (conversationId: string) => Promise<void>;
startNewConversation: () => void;
renameConversation: (conversationId: string, title: string) => Promise<void>;
removeConversation: (conversationId: string) => Promise<void>;
Expand Down Expand Up @@ -179,6 +187,32 @@ interface ChatState {
) => Promise<void>;
}

/**
* Build a conversation list row out of a metadata response.
*
* The two shapes differ in more than depth: metadata keys the conversation as
* `conversation_id` where the feed uses `id`, and carries no `created_at`. The id is passed
* in rather than read from the response so the row is guaranteed to match the conversation
* it was fetched for, which is what the rail highlights on. Only the fields the rail and the
* header actually read are mapped; everything else is left off rather than guessed at.
*/
function conversationFromMetadata(
conversationId: string,
metadata: ConversationMetadata,
): Conversation {
return {
id: conversationId,
title: metadata.title || 'Untitled conversation',
last_updated: metadata.last_updated,
is_pinned: metadata.is_pinned ?? false,
is_hidden: metadata.is_hidden ?? false,
has_unread_assistant_response: metadata.has_unread_assistant_response ?? false,
classification: metadata.classification ?? null,
context: metadata.context,
chat_type: metadata.chat_type ?? undefined,
};
}

/** Controller for the in-flight stream, kept outside the store as it is not render state. */
let activeStreamController: AbortController | null = null;

Expand Down Expand Up @@ -591,6 +625,43 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
},

/**
* Open a conversation named by the URL.
*
* The load itself is `selectConversation`'s job; what is different here is that a link
* can name a conversation that has been deleted, or one belonging to somebody else,
* whereas a row in the rail is one the server has just listed.
*
* Existence is checked against the metadata endpoint rather than inferred from the
* message load, because `/api/get_messages` is not an existence check: it turns a
* not-found conversation into `{'messages': []}` with a 200
* (`route_backend_conversations.py`), so a deleted conversation would open as an empty
* chat, keep its id in the address bar, and remain the target of the next message sent.
* The metadata endpoint answers 404 when the conversation is gone and 403 when it is
* someone else's, which is the question actually being asked. It costs one request on
* a path that runs once per page load.
*/
openLinkedConversation: async (conversationId) => {
try {
await fetchConversationMetadata(conversationId);
} catch {
toast.error(
'Could not open that conversation. It may have been deleted, or you may not have access to it.',
);
return;
}

await get().selectConversation(conversationId);

// Still checked: the conversation exists, but its messages may not have loaded.
if (get().messagesError) {
toast.error(
'Could not open that conversation. It may have been deleted, or you may not have access to it.',
);
get().startNewConversation();
}
},

startNewConversation: () => {
// Stop first: the running stream belongs to the previous thread and must not
// deliver its response into the empty new one.
Expand Down Expand Up @@ -838,7 +909,20 @@ export const useChatStore = create<ChatState>((set, get) => ({
set({ metadataLoading: false });
return;
}
set({ metadata, metadataLoading: false });
set((state) => ({
metadata,
metadataLoading: false,
// A conversation reached by a link can be older than the first page of the
// feed, or hidden, in which case the list has no row for it: the rail
// highlights nothing and the header falls back to "New chat" for a thread
// that is plainly open. Metadata is already being fetched here, so the row
// is built from it rather than costing another request. It goes to the top
// because the list is cursor-paged — there is no correct place to insert an
// older conversation into a page that has not been loaded.
conversations: state.conversations.some((item) => item.id === conversationId)
? state.conversations
: [conversationFromMetadata(conversationId, metadata), ...state.conversations],
}));
} catch (error) {
set({
metadataLoading: false,
Expand Down
38 changes: 38 additions & 0 deletions docs/explanation/features/REACT_V2_UI.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,43 @@ through Flask's built-in static handler and its caching, so no asset request eve
through the SPA catch-all. The shell itself is never cached, because it references
content-hashed asset filenames that change on every deploy.

### Linking to a conversation

`/v2/chat` names the conversation it has open, in the same query parameter the classic
interface uses:

```
/v2/chat?conversationId=<conversation-uuid>
```

The parameter is read when the chat page first renders, and written whenever the open
conversation changes — including when the server creates one on the first message sent, and
when a conversation is forked. Starting a new chat or deleting the open conversation
removes it. Copying the address bar therefore always shares what is on screen, and a
refresh returns to it rather than to an empty chat.

Both spellings are accepted on arrival. The server emits `conversationId` from notifications
and workflow runs, and `conversation_id` from chat responses and workspace document rows, so
a link built by any part of the application opens. Only `conversationId` is ever written: an
incoming `conversation_id` link is rewritten to it, so a URL never carries both.

The parameter is a description of what is open, not a navigation step. It is written with a
history *replace*, matching `history.replaceState` in the classic client, so opening ten
conversations does not put ten entries behind the back button.

A conversation reached this way need not be in the loaded conversation list — it can be
older than the first page of the feed, or hidden. Its list row is built from the metadata
the chat page already fetches, so the rail highlights it and the header shows its real
title. A link naming a conversation that has been deleted, or that belongs to somebody else,
reports that it cannot be opened and falls back to an empty chat; leaving the failure in
place would strand the parameter in the URL and reproduce the same error on every refresh.

**Back to classic UI** in the account menu carries the open conversation across as
`/chats?conversationId=<id>`, so crossing between the two interfaces keeps your place.

Server-generated links — notifications, workflow runs, document sources — still point at the
classic `/chats`. They are not interface-aware.

## API surface

### `GET /api/v2/bootstrap`
Expand Down Expand Up @@ -792,6 +829,7 @@ this entirely and is the recommended layout.
| `functional_tests/test_v2_rich_rendering.py` | Browser libraries vendored into the repository with their licences and pinned versions, no equivalent npm dependency, KaTeX fonts complete and locally resolvable, a sanitizer boundary at every HTML sink and nowhere else, KaTeX `trust: false`, mermaid strict with no autostart or icon packs, single `$` not treated as maths, fence wiring and chart language parity with the backend, charts copied as data, CSP unchanged |
| `functional_tests/test_v2_generated_image_lightbox.py` | The image thumbnail opens a dialog rather than a new tab, the dialog is dismissable and manages focus, every source kind is handled by download and open-in-new-tab, and `window.open` is not given `noopener` |
| `functional_tests/test_v2_chat_notices.py` | Both notices resolved server-side from the shared helpers, the web search notice's three-key condition including consent, all four AI notice frequencies, dismissal only after a successful write, session keys shared with the classic interface, no hardcoded disclaimer, notice text escaped |
| `functional_tests/test_v2_conversation_deep_link.py` | Both parameter spellings read and only the canonical one written, the incoming link captured before any effect can strip it, the URL replaced rather than pushed, a dead link reported instead of stranded, a list row backfilled behind the stale-response guard, and the classic handover carrying the conversation |
| `functional_tests/test_csrf_state_changing_route_guard.py` | Cross-site mutations require an explicitly trusted origin; CORS preflights answered before authentication and never wildcarded |
| `functional_tests/route_tests/` | Blueprint policy classification for `frontend_v2`, `backend_v2`, `backend_v2_admin` |

Expand Down
Loading