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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ All notable user-facing changes to PageSpace are documented here. Format follows

### Fixed

- **Global Assistant is no longer unclickable right after splitting a pane in the Agents console** —
a short pane's empty-pane picker could crush its "Shell"/"Global Assistant" choices under the
"Agents" list below them, so the Global Assistant button visually collided with — and lost clicks
to — the section beneath it. The picker's sections no longer shrink below their own content.
- **The Agents console's session list no longer shows a distracting scrollbar** — the sidebar's
left-hand session list now scrolls without ever painting a scrollbar, matching the rest of the
console's chrome-free feel.
- **A Global Assistant conversation started from the global Agents dashboard now keeps the right
drive's context** — picking Global Assistant for a specific drive from `/dashboard/agents` (rather
than that drive's own Agents page) minted a session correctly scoped to that drive, but the
assistant itself had no way to know which drive it was in — it derived that from the current page,
and the Agents console never navigates as you click between sessions. It now reads the drive
straight from its own session instead.
- **A database outage no longer takes the whole platform down with it** — when Postgres
became unreachable (as in the recent OOM stalls), the rate limiter denied every request
platform-wide, turning a database incident into a total outage. Production now degrades to
Expand Down Expand Up @@ -53,6 +66,11 @@ All notable user-facing changes to PageSpace are documented here. Format follows

### Added

- **Start a session with Global Assistant directly from a drive** — clicking "+" on a drive in the
Agents console now offers Global Assistant alongside that drive's own agents and Shell, first in
the list. The new session is filed under that drive, same as any agent session, rather than only
being reachable as a driveless global session. Its default name now also reads "Global Assistant"
(was "Assistant") everywhere a session or conversation goes unnamed.
- **Rotate a webhook secret in place** — the Incoming Webhooks dialog (and
`POST /api/pages/[pageId]/webhooks/[id]/rotate`) now mints a fresh signing secret for the **same
webhook URL**, so replacing a lost or leaked secret no longer means deleting the webhook and
Expand Down
32 changes: 23 additions & 9 deletions apps/web/src/app/api/agent-sessions/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,12 @@ describe('POST /api/agent-sessions — spawn', () => {
// the absence is structural.
});

it('spawns a GLOBAL-ASSISTANT session from the both-null shape, auto-naming it "Assistant"', async () => {
it('spawns a GLOBAL-ASSISTANT session from the both-null shape, auto-naming it "Global Assistant"', async () => {
const response = await spawn({});
expect(response.status).toBe(201);
const body = await response.json();
expect(typeof body.conversationId).toBe('string');
expect(mockSpawnSession).toHaveBeenCalledWith({ userId: 'user-1', driveId: null, name: 'Assistant' });
expect(mockSpawnSession).toHaveBeenCalledWith({ userId: 'user-1', driveId: null, name: 'Global Assistant' });
// The first conversation is an assistant thread: no agent page.
expect(mockCreateConversationInSession).toHaveBeenCalledWith(
expect.objectContaining({ agentPageId: null, sessionId: 'ses-new' }),
Expand All @@ -220,11 +220,25 @@ describe('POST /api/agent-sessions — spawn', () => {
);
});

it('refuses the half-specified shapes — an agent needs its drive, a drive needs an agent', async () => {
for (const body of [{ driveId: 'drive-1' }, { agentPageId: 'agent-1' }]) {
const response = await spawn(body);
expect(response.status).toBe(400);
}
it('spawns a DRIVE session from the drive-only shape, first conversation is the assistant', async () => {
const response = await spawn({ driveId: 'drive-1' });
expect(response.status).toBe(201);
const body = await response.json();
expect(typeof body.conversationId).toBe('string');
expect(mockSpawnSession).toHaveBeenCalledWith({ userId: 'user-1', driveId: 'drive-1', name: 'Global Assistant' });
expect(mockCreateConversationInSession).toHaveBeenCalledWith(
expect.objectContaining({ agentPageId: null, sessionId: 'ses-new' }),
);
// The access decision saw the DRIVE subject, not the global one.
expect(mockCheckAccessForSubject).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({ driveId: 'drive-1' }),
);
});

it('refuses the one unresolvable shape — an agent without its drive', async () => {
const response = await spawn({ agentPageId: 'agent-1' });
expect(response.status).toBe(400);
expect(mockSpawnSession).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -469,11 +483,11 @@ describe('POST /api/agent-sessions — blank name auto-generates a unique one',
);
});

it('given a blank name for the global-assistant shape, should derive the base label "Assistant"', async () => {
it('given a blank name for the global-assistant shape, should derive the base label "Global Assistant"', async () => {
const response = await spawn({});
expect(response.status).toBe(201);
expect(mockSpawnSession).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Assistant' }),
expect.objectContaining({ name: 'Global Assistant' }),
);
});

Expand Down
34 changes: 21 additions & 13 deletions apps/web/src/app/api/agent-sessions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,17 @@ export async function GET(request: Request) {
*
* One act, per the lifecycle invariant: a session is born with its first
* thing, so this creates the workspace row AND that first thing already bound
* to it. Two shapes, matching the two session kinds:
* to it. Three shapes:
*
* - `{ driveId, agentPageId }` — a DRIVE session, first conversation with that
* drive agent.
* - `{ driveId }` (`agentPageId` null/omitted) — a DRIVE session whose first
* conversation is the assistant instead of a specific agent. The
* conversation and access layers don't care whether a null-agent
* conversation's session has a drive (`createConversationInSession`'s
* `agentPageId: null` branch and `decideAgentSessionAccess` both key only
* on `driveId`) — a drive session can already host such a conversation via
* the split-pane picker, this just allows it as the FIRST thing too.
* - `{}` (both null/omitted) — a GLOBAL-ASSISTANT session: no drive, first
* conversation is a `type: 'global'` assistant thread. Owner-only by the
* access decision (there is no drive whose membership could admit anyone
Expand All @@ -133,13 +140,13 @@ export async function GET(request: Request) {
* (`provisionSessionSandbox` + `spawnShell`) rather than reimplementing it.
* Omitted/anything else behaves exactly as before — the conversation path.
*
* Half-specified shapes are refused: an agent without its drive is
* unresolvable, and a drive without an agent would mint an empty session —
* UNLESS `firstThing: 'shell'`, whose first thing needs no agent.
* Only one shape is refused: an `agentPageId` without a `driveId` is
* unresolvable (which drive's agent?) — UNLESS `firstThing: 'shell'`, whose
* first thing needs no agent at all.
*
* A blank/omitted `name` is not left null: it is auto-derived from the spawn
* target (the agent's title, "Shell", or "Assistant") and made unique among
* the owner's own existing session names before the row is inserted.
* target (the agent's title, "Shell", or "Global Assistant") and made unique
* among the owner's own existing session names before the row is inserted.
*
* Spawn itself is instant and free: NO sandbox is provisioned for the
* conversation path. `firstThing: 'shell'` is the exception — opening a shell
Expand All @@ -166,13 +173,14 @@ export async function POST(request: Request) {
const rawName = typeof body.name === 'string' ? body.name.trim() : '';
const wantsShellFirst = body.firstThing === 'shell';

if (!wantsShellFirst && (driveId === null) !== (agentPageId === null)) {
// Half-specified: an agent without its drive is unresolvable, and a drive
// without an agent would mint an empty session. Both-null is the
// global-assistant spawn; both-present is the drive spawn. A shell-first
// spawn needs no agent, so it is exempt from this shape check.
if (!wantsShellFirst && driveId === null && agentPageId !== null) {
// The only truly unresolvable shape: an agent without its drive. Every
// other combination is valid — both-present (drive session with that
// agent), drive-only (drive session, assistant-first), and both-null
// (global-assistant session). A shell-first spawn needs no agent, so it
// is exempt from this check entirely.
return NextResponse.json(
{ error: 'A session needs a drive and an agent to start with, or neither for the assistant' },
{ error: 'An agent needs its drive to start a session' },
{ status: 400 },
);
}
Expand Down Expand Up @@ -259,7 +267,7 @@ export async function POST(request: Request) {
if (rawName.length > 0) {
name = rawName.slice(0, MAX_SESSION_NAME_LENGTH);
} else {
const baseLabel = wantsShellFirst ? 'Shell' : agentPageId !== null ? (agentTitle ?? 'Agent') : 'Assistant';
const baseLabel = wantsShellFirst ? 'Shell' : agentPageId !== null ? (agentTitle ?? 'Agent') : 'Global Assistant';
const existingSessions = await listSessions({ ownerId: auth.userId });
const existingNames = existingSessions.map((session) => session.name);
name = nextUniqueSessionName(baseLabel, existingNames).slice(0, MAX_SESSION_NAME_LENGTH);
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,16 @@ svg.lucide {
background-color: hsl(var(--accent));
}

/* Fully hidden scrollbar — scrolling still works, the bar never renders */
.scrollbar-none {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* legacy Edge */
}

.scrollbar-none::-webkit-scrollbar {
display: none; /* Chrome/Safari/Electron */
}

/* SVG Color Utilities - for dark mode support in SVG elements */
.svg-bg {
fill: var(--background);
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/agents/chat/AssistantSessionChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@ import { useAssistantSessionChat } from './useAssistantSessionChat';

export default function AssistantSessionChat({
conversationId,
driveId,
context,
isReadOnly = false,
}: {
conversationId: string;
/** The session's own drive, when it has one — see `useAssistantSessionChat`'s doc. */
driveId: string | null;
context: 'page' | 'console';
isReadOnly?: boolean;
}) {
const chat = useAssistantSessionChat({ conversationId });
const chat = useAssistantSessionChat({ conversationId, driveId });
const currentModel = useAssistantSettingsStore((state) => state.currentModel);

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* useAssistantSessionChat Hook Tests.
*
* Behavior under test: which `contextRef` a send carries. The Agents console
* never navigates as sessions/panes are clicked, and `agents` isn't even a
* route `buildContextRef` recognizes as drive-scoped (see `tab-title.ts`) —
* so the pathname can never tell this hook which drive a DRIVE-SCOPED
* session's Global Assistant conversation belongs to. The session's own
* `driveId` (known statically by whoever mounts this hook) must win over
* pathname parsing whenever the session has one; pathname parsing remains
* the only signal for a driveless global-assistant session, which has none.
*
* renderHook, auth-fetch mocked, real swr, real conversation-messages store —
* same minimal-mock strategy as `useAgentSessionChat.test.ts`.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import type { UIMessage } from 'ai';

const { mockFetchWithAuth } = vi.hoisted(() => ({
mockFetchWithAuth: vi.fn(),
}));

vi.mock('@/lib/auth/auth-fetch', () => ({
fetchWithAuth: mockFetchWithAuth,
}));

const chat = vi.hoisted(() => ({
capturedConfigs: [] as Array<Record<string, unknown>>,
instance: {
sendMessage: vi.fn(),
regenerate: vi.fn(),
setMessages: vi.fn(),
stop: vi.fn(),
clearError: vi.fn(),
addToolResult: vi.fn(),
},
}));

vi.mock('@ai-sdk/react', () => ({
useChat: vi.fn((config: Record<string, unknown> | undefined) => {
if (config && typeof config.id === 'string') chat.capturedConfigs.push(config);
return {
messages: [] as UIMessage[],
status: 'ready' as const,
error: undefined,
...chat.instance,
};
}),
}));

const loaders = vi.hoisted(() => ({
loadGlobalConversationMessages: vi.fn(async () => {}),
loadOlderGlobalConversationMessages: vi.fn(async () => {}),
}));
vi.mock('@/hooks/conversationMessagesLoaders', () => loaders);

vi.mock('@/hooks/useActiveStream', () => ({
useActiveStream: () => ({ streams: [] }),
useConversationActiveStream: () => undefined,
}));

const route = vi.hoisted(() => ({ pathname: '/dashboard/agents' }));
vi.mock('next/navigation', () => ({
usePathname: () => route.pathname,
}));

vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({ user: { id: 'user-1', name: 'Test User', email: 'test@example.com' } }),
}));

vi.mock('sonner', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));

// Real `useGlobalChatConversation` throws outside a `GlobalChatProvider` —
// this hook doesn't mount one, only reads the rejoin callback off it.
vi.mock('@/contexts/GlobalChatContext', () => ({
useGlobalChatConversation: () => ({ rejoinGlobalStream: vi.fn() }),
}));

import { useDriveStore, type Drive } from '@/hooks/useDrive';
import { useConversationMessagesStore } from '@/stores/useConversationMessagesStore';
import { conversationMessagesActions } from '@/hooks/conversationMessagesActions';
import { useAssistantSessionChat } from '../useAssistantSessionChat';

function ids(slug: string) {
return { conversationId: `conv-${slug}` };
}

function driveFixture(id: string, name: string): Drive {
return {
id,
name,
slug: name.toLowerCase(),
ownerId: 'user-1',
isTrashed: false,
trashedAt: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
isOwned: true,
};
}

function sentBody() {
const [, options] = chat.instance.sendMessage.mock.calls.at(-1) as [
UIMessage,
{ body?: Record<string, unknown> },
];
return options.body;
}

beforeEach(() => {
vi.clearAllMocks();
chat.capturedConfigs.length = 0;
route.pathname = '/dashboard/agents';
useConversationMessagesStore.setState({ byConversationId: {} });
useDriveStore.setState({ drives: [driveFixture('drive-1', 'Engineering')] });
mockFetchWithAuth.mockResolvedValue({ ok: true, json: async () => ({}) });
});

describe('useAssistantSessionChat — contextRef', () => {
it("sends routeType 'drive' with the SESSION's own driveId when the session has one, ignoring the pathname entirely", async () => {
const { conversationId } = ids('drive-scoped');
conversationMessagesActions.seedConversation(conversationId);
// The global console route — buildContextRef alone would resolve this to
// routeType 'other' (agents isn't a route it recognizes at all).
route.pathname = '/dashboard/agents';

const { result } = renderHook(() => useAssistantSessionChat({ conversationId, driveId: 'drive-1' }));
await act(async () => {
await result.current.handleSend('hello');
});

await waitFor(() => expect(chat.instance.sendMessage).toHaveBeenCalledTimes(1));
expect(sentBody()).toEqual(
expect.objectContaining({ contextRef: { routeType: 'drive', driveId: 'drive-1' } }),
);
});

it('still uses the session driveId even while standing on an UNRELATED page in a different drive', async () => {
const { conversationId } = ids('drive-scoped-elsewhere');
conversationMessagesActions.seedConversation(conversationId);
useDriveStore.setState({
drives: [driveFixture('drive-1', 'Engineering'), driveFixture('drive-2', 'Design')],
});
// Main view is showing a page in drive-2; this pane's session is drive-1's.
route.pathname = '/dashboard/drive-2/some-page';

const { result } = renderHook(() => useAssistantSessionChat({ conversationId, driveId: 'drive-1' }));
await act(async () => {
await result.current.handleSend('put this here');
});

await waitFor(() => expect(chat.instance.sendMessage).toHaveBeenCalledTimes(1));
expect(sentBody()).toEqual(
expect.objectContaining({ contextRef: { routeType: 'drive', driveId: 'drive-1' } }),
);
});

it('falls back to the pathname-derived context for a DRIVELESS global-assistant session', async () => {
const { conversationId } = ids('driveless');
conversationMessagesActions.seedConversation(conversationId);
route.pathname = '/dashboard/drive-1/page-1';

const { result } = renderHook(() => useAssistantSessionChat({ conversationId, driveId: null }));
await act(async () => {
await result.current.handleSend('hello');
});

await waitFor(() => expect(chat.instance.sendMessage).toHaveBeenCalledTimes(1));
expect(sentBody()).toEqual(
expect.objectContaining({
contextRef: { routeType: 'page', pageId: 'page-1', driveId: 'drive-1' },
}),
);
});
});
Loading