Skip to content

Commit 5cb9240

Browse files
authored
refactor(dashboard): unify AI chat surfaces on assistant-ui Thread (#1427)
## Summary - Replace the bespoke `ai-chat-shared` chat UI (used by ask-ai, the stack companion widget, vibe coding chat, and the create-dashboard preview) with the shared `assistant-ui` `Thread` component. - Extract streaming request/format helpers into a new `components/assistant-ui/chat-stream.ts` module so each surface only owns its `ChatModelAdapter`. - Add a reusable `ToolFallback` for tool-call rendering and delete the now-unused `ai-chat-shared.tsx` (-1386 / +747 lines net). Stacked on top of `refactor/data-grid-and-dashboard-surfaces`. Base: `refactor/data-grid-and-dashboard-surfaces` → Head: `refactor/assistant-ui-chat-surfaces` · 18 files changed > Red outlines on the **after** shots mark the unified `assistant-ui` `Thread` surface in each location. ## Screenshots ### Analytics → Tables — AI Query dialog | | Before | After | |---|---|---| | **Light** | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/analytics-tables-ai-before-light.png" width="480" /> | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/analytics-tables-ai-after-light.png" width="480" /> | | **Dark** | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/analytics-tables-ai-before-dark.png" width="480" /> | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/analytics-tables-ai-after-dark.png" width="480" /> | ### Stack Companion — chat widget | | Before | After | |---|---|---| | **Light** | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/stack-companion-before-light.png" width="480" /> | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/stack-companion-after-light.png" width="480" /> | | **Dark** | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/stack-companion-before-dark.png" width="480" /> | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/stack-companion-after-dark.png" width="480" /> | ### Ask-AI command palette (⌘K → Ask AI) | | Before | After | |---|---|---| | **Light** | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/ask-ai-cmdk-before-light.png" width="480" /> | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/ask-ai-cmdk-after-light.png" width="480" /> | | **Dark** | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/ask-ai-cmdk-before-dark.png" width="480" /> | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/ask-ai-cmdk-after-dark.png" width="480" /> | ### Email editor — embedded chat panel | | Before | After | |---|---|---| | **Light** | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/email-editor-chat-before-light.png" width="480" /> | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/email-editor-chat-after-light.png" width="480" /> | | **Dark** | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/email-editor-chat-before-dark.png" width="480" /> | <img src="https://gist.githubusercontent.com/mantrakp04/323851437f41145aab12a27fb6c392b4/raw/email-editor-chat-after-dark.png" width="480" /> | ## Notes for reviewers The four surfaces above all previously shared `components/commands/ai-chat-shared.tsx` (516 lines, deleted). After this PR they each own a thin `ChatModelAdapter` and render through `components/assistant-ui/thread.tsx` + the new `chat-stream.ts` helpers. Visual differences between **before** and **after** are intentional — the `assistant-ui` `Thread` brings its own message bubbles, scroll-to-bottom behaviour, composer, and `ToolFallback` rendering. The email editor's chat panel is the surface where the behaviour change is most visible (tool-call rendering now consistent with the rest of the app). Heaviest changes (lines): - `components/stack-companion/ai-chat-widget.tsx` (571) - `components/commands/ai-chat-shared.tsx` (516, deleted) - `analytics/tables/ai-query-dialog.tsx` (429) - `components/vibe-coding/chat-adapters.ts` (400) - `components/assistant-ui/chat-stream.ts` (284, new) - `components/commands/ask-ai.tsx` (274) - `components/assistant-ui/thread.tsx` (115) - `components/assistant-ui/tool-fallback.tsx` (113) ## Test plan - [ ] `pnpm lint` - [ ] `pnpm typecheck` - [ ] Manually exercise each affected surface: command-center Ask AI, stack-companion widget, vibe-coding chat, analytics tables AI query, create-dashboard preview, email editor chat. - [ ] Verify tool-call chips render consistently across all four surfaces (uses the new `ToolFallback`). - [ ] Verify streaming + cancel works on each adapter (`chat-stream.ts` is shared).
1 parent c808e23 commit 5cb9240

28 files changed

Lines changed: 1432 additions & 2037 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
2+
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
3+
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
4+
5+
// Binary search: index of the first item whose id > cursor, in an
6+
// array already sorted by `stringCompare(a.id, b.id)`.
7+
function firstIndexAfter<T extends { id: string }>(sorted: T[], cursor: string): number {
8+
let lo = 0;
9+
let hi = sorted.length;
10+
while (lo < hi) {
11+
const mid = (lo + hi) >>> 1;
12+
if (stringCompare(sorted[mid].id, cursor) <= 0) lo = mid + 1;
13+
else hi = mid;
14+
}
15+
return lo;
16+
}
17+
18+
type PermissionDefinition = {
19+
id: string,
20+
description?: string,
21+
contained_permission_ids: string[],
22+
};
23+
24+
type ListQuery = {
25+
limit?: number,
26+
cursor?: string,
27+
query?: string,
28+
};
29+
30+
export const permissionDefinitionsListQuerySchema = yupObject({
31+
limit: yupNumber().integer().min(1).max(200).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Maximum number of items to return (capped at 200). When set, the response is paginated via cursor." } }),
32+
cursor: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Cursor (permission id) to start the next page from. Requires `limit` to also be set." } }),
33+
query: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Free-text filter applied to permission id and description (case-insensitive)." } }),
34+
});
35+
36+
export function paginatePermissionDefinitions(items: PermissionDefinition[], query: ListQuery) {
37+
if (query.cursor != null && query.limit === undefined) {
38+
throw new StatusError(StatusError.BadRequest, "`cursor` requires `limit` to also be set.");
39+
}
40+
41+
const search = query.query?.trim().toLowerCase();
42+
const filtered = (search
43+
? items.filter((p) =>
44+
p.id.toLowerCase().includes(search)
45+
|| (p.description?.toLowerCase().includes(search) ?? false))
46+
: items.slice()
47+
).sort((a, b) => stringCompare(a.id, b.id));
48+
49+
if (query.limit === undefined) {
50+
return { items: filtered, is_paginated: false as const };
51+
}
52+
53+
let startIdx = 0;
54+
if (query.cursor != null) {
55+
const cursorIdx = filtered.findIndex((p) => p.id === query.cursor);
56+
// If the cursor row was deleted (or filtered out) between page
57+
// requests, fall back to "first id strictly greater than the cursor"
58+
// rather than 400'ing the client mid-scroll. Worst case the user
59+
// sees a one-row gap; the alternative is a hard error on infinite
60+
// scroll for any concurrent edit.
61+
startIdx = cursorIdx === -1
62+
? firstIndexAfter(filtered, query.cursor)
63+
: cursorIdx + 1;
64+
}
65+
const slice = filtered.slice(startIdx, startIdx + query.limit);
66+
const hasMore = startIdx + query.limit < filtered.length;
67+
68+
return {
69+
items: slice,
70+
is_paginated: true as const,
71+
pagination: {
72+
next_cursor: hasMore && slice.length > 0 ? slice[slice.length - 1].id : null,
73+
},
74+
};
75+
}

apps/backend/src/app/api/latest/team-permission-definitions/crud.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler";
44
import { teamPermissionDefinitionsCrud } from '@stackframe/stack-shared/dist/interface/crud/team-permissions';
55
import { permissionDefinitionIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
66
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
7+
import { paginatePermissionDefinitions, permissionDefinitionsListQuerySchema } from "../permission-definitions-pagination";
78

89
export const teamPermissionDefinitionsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamPermissionDefinitionsCrud, {
910
paramsSchema: yupObject({
1011
permission_id: permissionDefinitionIdSchema.defined(),
1112
}),
13+
querySchema: permissionDefinitionsListQuerySchema,
1214
async onCreate({ auth, data }) {
1315
return await createPermissionDefinition(
1416
globalPrismaClient,
@@ -48,13 +50,11 @@ export const teamPermissionDefinitionsCrudHandlers = createLazyProxy(() => creat
4850
}
4951
);
5052
},
51-
async onList({ auth }) {
52-
return {
53-
items: await listPermissionDefinitions({
54-
scope: "team",
55-
tenancy: auth.tenancy,
56-
}),
57-
is_paginated: false,
58-
};
53+
async onList({ auth, query }) {
54+
const all = await listPermissionDefinitions({
55+
scope: "team",
56+
tenancy: auth.tenancy,
57+
});
58+
return paginatePermissionDefinitions(all, query);
5959
},
6060
}));

apps/backend/src/app/api/latest/users/crud.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -629,9 +629,13 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
629629
},
630630
{ projectUserId: sortDirection },
631631
],
632-
// +1 because we need to know if there is a next page
632+
// +1 to detect whether a next page exists without a separate count.
633633
take: query.limit ? query.limit + 1 : undefined,
634+
// Cursor convention (matches teams/crud.tsx): the client sends the
635+
// id of the LAST row of the previous page; Prisma starts AT that id,
636+
// and `skip: 1` drops it so we don't re-emit it.
634637
...query.cursor ? {
638+
skip: 1,
635639
cursor: {
636640
tenancyId_projectUserId: {
637641
tenancyId: auth.tenancy.id,
@@ -641,13 +645,13 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
641645
} : {},
642646
});
643647

648+
const items = db.slice(0, query.limit).map((user) => userPrismaToCrud(user, auth.tenancy.config));
649+
const hasMore = query.limit != null && db.length > query.limit;
644650
return {
645-
// remove the last item because it's the next cursor
646-
items: db.map((user) => userPrismaToCrud(user, auth.tenancy.config)).slice(0, query.limit),
651+
items,
647652
is_paginated: true,
648653
pagination: {
649-
// if result is not full length, there is no next cursor
650-
next_cursor: query.limit && db.length >= query.limit + 1 ? db[db.length - 1].projectUserId : null,
654+
next_cursor: hasMore && items.length > 0 ? items[items.length - 1].id : null,
651655
},
652656
};
653657
},

0 commit comments

Comments
 (0)