@chat-js/cli 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/index.js +1173 -964
  2. package/package.json +1 -1
  3. package/templates/chat-app/app/(auth)/device-login/page.tsx +37 -0
  4. package/templates/chat-app/app/(auth)/login/page.tsx +26 -2
  5. package/templates/chat-app/app/(auth)/register/page.tsx +0 -12
  6. package/templates/chat-app/app/(chat)/api/chat/filter-reasoning-parts.ts +1 -1
  7. package/templates/chat-app/app/(chat)/api/chat/prepare/route.ts +94 -0
  8. package/templates/chat-app/app/(chat)/api/chat/route.ts +107 -16
  9. package/templates/chat-app/app/(chat)/layout.tsx +4 -1
  10. package/templates/chat-app/app/api/trpc/[trpc]/route.ts +1 -0
  11. package/templates/chat-app/app/globals.css +9 -9
  12. package/templates/chat-app/app/layout.tsx +4 -2
  13. package/templates/chat-app/biome.jsonc +3 -3
  14. package/templates/chat-app/chat.config.ts +32 -12
  15. package/templates/chat-app/components/ai-elements/prompt-input.tsx +1 -1
  16. package/templates/chat-app/components/anonymous-session-init.tsx +10 -6
  17. package/templates/chat-app/components/artifact-actions.tsx +81 -18
  18. package/templates/chat-app/components/artifact-panel.tsx +142 -41
  19. package/templates/chat-app/components/attachment-list.tsx +1 -1
  20. package/templates/chat-app/components/{social-auth-providers.tsx → auth-providers.tsx} +49 -4
  21. package/templates/chat-app/components/chat/chat-welcome.tsx +3 -3
  22. package/templates/chat-app/components/chat-menu-items.tsx +1 -1
  23. package/templates/chat-app/components/chat-sync.tsx +9 -11
  24. package/templates/chat-app/components/console.tsx +9 -9
  25. package/templates/chat-app/components/context-usage.tsx +2 -2
  26. package/templates/chat-app/components/create-artifact.tsx +15 -5
  27. package/templates/chat-app/components/data-stream-handler.tsx +57 -16
  28. package/templates/chat-app/components/device-login-page.tsx +191 -0
  29. package/templates/chat-app/components/diffview.tsx +8 -2
  30. package/templates/chat-app/components/electron-auth-handler.tsx +184 -0
  31. package/templates/chat-app/components/electron-auth-ui.tsx +121 -0
  32. package/templates/chat-app/components/favicon-group.tsx +1 -1
  33. package/templates/chat-app/components/feedback-actions.tsx +7 -3
  34. package/templates/chat-app/components/greeting.tsx +1 -1
  35. package/templates/chat-app/components/interactive-chart-impl.tsx +3 -4
  36. package/templates/chat-app/components/interactive-charts.tsx +1 -1
  37. package/templates/chat-app/components/login-form.tsx +52 -10
  38. package/templates/chat-app/components/message-editor.tsx +7 -3
  39. package/templates/chat-app/components/message-siblings.tsx +14 -1
  40. package/templates/chat-app/components/model-selector.tsx +295 -27
  41. package/templates/chat-app/components/multimodal-input.tsx +259 -22
  42. package/templates/chat-app/components/parallel-response-cards.tsx +175 -0
  43. package/templates/chat-app/components/part/code-execution.tsx +8 -2
  44. package/templates/chat-app/components/part/document-common.tsx +1 -1
  45. package/templates/chat-app/components/part/document-preview.tsx +5 -5
  46. package/templates/chat-app/components/part/retrieve-url.tsx +12 -12
  47. package/templates/chat-app/components/part/text-message-part.tsx +9 -1
  48. package/templates/chat-app/components/project-chat-item.tsx +1 -1
  49. package/templates/chat-app/components/project-menu-items.tsx +1 -1
  50. package/templates/chat-app/components/research-task.tsx +1 -1
  51. package/templates/chat-app/components/research-tasks.tsx +1 -1
  52. package/templates/chat-app/components/retry-button.tsx +25 -8
  53. package/templates/chat-app/components/sandbox.tsx +1 -1
  54. package/templates/chat-app/components/sheet-editor.tsx +7 -7
  55. package/templates/chat-app/components/sidebar-chats-list.tsx +1 -1
  56. package/templates/chat-app/components/sidebar-toggle.tsx +15 -2
  57. package/templates/chat-app/components/sidebar-top-row.tsx +27 -12
  58. package/templates/chat-app/components/sidebar-user-nav.tsx +10 -1
  59. package/templates/chat-app/components/signup-form.tsx +49 -10
  60. package/templates/chat-app/components/sources.tsx +4 -4
  61. package/templates/chat-app/components/text-editor.tsx +5 -2
  62. package/templates/chat-app/components/toolbar.tsx +3 -3
  63. package/templates/chat-app/components/ui/sidebar.tsx +0 -1
  64. package/templates/chat-app/components/upgrade-cta/limit-display.tsx +1 -1
  65. package/templates/chat-app/components/user-message.tsx +14 -2
  66. package/templates/chat-app/electron.d.ts +41 -0
  67. package/templates/chat-app/evals/my-eval.eval.ts +3 -1
  68. package/templates/chat-app/hooks/chat-sync-hooks.ts +11 -0
  69. package/templates/chat-app/hooks/use-artifact.tsx +13 -13
  70. package/templates/chat-app/hooks/use-navigate-to-message.ts +39 -0
  71. package/templates/chat-app/lib/ai/gateways/provider-types.ts +19 -10
  72. package/templates/chat-app/lib/ai/stream-errors.test.ts +72 -0
  73. package/templates/chat-app/lib/ai/stream-errors.ts +94 -0
  74. package/templates/chat-app/lib/ai/tools/code-execution.javascript.ts +171 -0
  75. package/templates/chat-app/lib/ai/tools/code-execution.python.ts +336 -0
  76. package/templates/chat-app/lib/ai/tools/code-execution.shared.test.ts +71 -0
  77. package/templates/chat-app/lib/ai/tools/code-execution.shared.ts +59 -0
  78. package/templates/chat-app/lib/ai/tools/code-execution.ts +62 -391
  79. package/templates/chat-app/lib/ai/tools/code-execution.types.ts +24 -0
  80. package/templates/chat-app/lib/ai/tools/steps/multi-query-web-search.ts +3 -2
  81. package/templates/chat-app/lib/ai/types.ts +74 -3
  82. package/templates/chat-app/lib/anonymous-session-client.ts +0 -3
  83. package/templates/chat-app/lib/artifacts/code/client.tsx +35 -5
  84. package/templates/chat-app/lib/artifacts/sheet/client.tsx +11 -3
  85. package/templates/chat-app/lib/auth-client.ts +23 -1
  86. package/templates/chat-app/lib/auth.ts +18 -1
  87. package/templates/chat-app/lib/blob.ts +1 -1
  88. package/templates/chat-app/lib/clone-messages.ts +1 -1
  89. package/templates/chat-app/lib/config-schema.ts +18 -1
  90. package/templates/chat-app/lib/constants.ts +3 -4
  91. package/templates/chat-app/lib/db/migrations/0044_gray_red_shift.sql +5 -0
  92. package/templates/chat-app/lib/db/migrations/meta/0044_snapshot.json +1480 -0
  93. package/templates/chat-app/lib/db/migrations/meta/_journal.json +7 -0
  94. package/templates/chat-app/lib/db/queries.ts +84 -4
  95. package/templates/chat-app/lib/db/schema.ts +4 -1
  96. package/templates/chat-app/lib/editor/config.ts +4 -4
  97. package/templates/chat-app/lib/electron-auth.ts +96 -0
  98. package/templates/chat-app/lib/env-schema.ts +33 -4
  99. package/templates/chat-app/lib/message-conversion.ts +14 -2
  100. package/templates/chat-app/lib/playwright-test-environment.ts +18 -0
  101. package/templates/chat-app/lib/social-auth.ts +5 -0
  102. package/templates/chat-app/lib/stores/hooks-threads.ts +38 -1
  103. package/templates/chat-app/lib/stores/with-threads.test.ts +137 -0
  104. package/templates/chat-app/lib/stores/with-threads.ts +159 -7
  105. package/templates/chat-app/lib/stores/with-tracing.ts +1 -1
  106. package/templates/chat-app/lib/thread-utils.ts +22 -3
  107. package/templates/chat-app/lib/utils/download-assets.ts +6 -7
  108. package/templates/chat-app/lib/utils/rate-limit.ts +9 -3
  109. package/templates/chat-app/package.json +20 -18
  110. package/templates/chat-app/playwright.config.ts +0 -19
  111. package/templates/chat-app/providers/chat-input-provider.tsx +40 -2
  112. package/templates/chat-app/proxy.ts +28 -3
  113. package/templates/chat-app/scripts/check-env.ts +10 -0
  114. package/templates/chat-app/scripts/db-branch-delete.sh +7 -1
  115. package/templates/chat-app/scripts/db-branch-use.sh +7 -1
  116. package/templates/chat-app/scripts/with-db.sh +7 -1
  117. package/templates/chat-app/trpc/server.tsx +7 -2
  118. package/templates/chat-app/tsconfig.json +2 -1
  119. package/templates/chat-app/vercel.json +0 -10
  120. package/templates/chat-app/vitest.config.ts +2 -0
  121. package/templates/electron/CHANGELOG.md +7 -0
  122. package/templates/electron/README.md +54 -0
  123. package/templates/electron/entitlements.mac.plist +10 -0
  124. package/templates/electron/forge.config.ts +157 -0
  125. package/templates/electron/icon.png +0 -0
  126. package/templates/electron/package.json +53 -0
  127. package/templates/electron/scripts/generate-icons.test.js +37 -0
  128. package/templates/electron/scripts/generate-icons.ts +29 -0
  129. package/templates/electron/scripts/run-forge.cjs +28 -0
  130. package/templates/electron/scripts/write-branding.ts +18 -0
  131. package/templates/electron/src/config.ts +16 -0
  132. package/templates/electron/src/lib/auth-client.ts +64 -0
  133. package/templates/electron/src/main.ts +670 -0
  134. package/templates/electron/src/preload.d.ts +27 -0
  135. package/templates/electron/src/preload.ts +25 -0
  136. package/templates/electron/tsconfig.json +18 -0
@@ -19,6 +19,12 @@ export interface MessageSiblingInfo<UM> {
19
19
  siblings: UM[];
20
20
  }
21
21
 
22
+ export interface ParallelGroupInfo<UM> {
23
+ messages: UM[];
24
+ parallelGroupId: string;
25
+ selectedMessageId: string | null;
26
+ }
27
+
22
28
  export type ThreadAugmentedState<UM extends UIMessage> =
23
29
  BaseChatStoreState<UM> & {
24
30
  threadEpoch: number;
@@ -41,6 +47,7 @@ export type ThreadAugmentedState<UM extends UIMessage> =
41
47
  addMessageToTree: (message: UM) => void;
42
48
  /** Look up sibling info for a message. */
43
49
  getMessageSiblingInfo: (messageId: string) => MessageSiblingInfo<UM> | null;
50
+ getParallelGroupInfo: (messageId: string) => ParallelGroupInfo<UM> | null;
44
51
  /**
45
52
  * Switch to a sibling thread. Returns the new thread array,
46
53
  * or null if no switch was possible.
@@ -49,6 +56,7 @@ export type ThreadAugmentedState<UM extends UIMessage> =
49
56
  messageId: string,
50
57
  direction: "prev" | "next"
51
58
  ) => UM[] | null;
59
+ switchToMessage: (messageId: string) => UM[] | null;
52
60
  };
53
61
 
54
62
  export const withThreads =
@@ -64,6 +72,33 @@ export const withThreads =
64
72
  const rebuildMap = (msgs: UI_MESSAGE[]) =>
65
73
  buildChildrenMap(msgs as (UI_MESSAGE & MessageNode)[]);
66
74
 
75
+ const mergeTreeMessages = (
76
+ serverMessages: UI_MESSAGE[],
77
+ existingTreeMessages: UI_MESSAGE[],
78
+ currentVisibleMessages: UI_MESSAGE[]
79
+ ): UI_MESSAGE[] => {
80
+ const merged = new Map<string, UI_MESSAGE>();
81
+
82
+ for (const message of serverMessages) {
83
+ merged.set(message.id, message);
84
+ }
85
+
86
+ // Preserve every local-only tree node until the server returns a message with
87
+ // the same id. Restricting this to pending assistant shells orphaned optimistic
88
+ // user messages when switching away from an in-flight branch mid-stream.
89
+ for (const message of existingTreeMessages) {
90
+ if (!merged.has(message.id)) {
91
+ merged.set(message.id, message);
92
+ }
93
+ }
94
+
95
+ for (const message of currentVisibleMessages) {
96
+ merged.set(message.id, message);
97
+ }
98
+
99
+ return Array.from(merged.values());
100
+ };
101
+
67
102
  return {
68
103
  ...base,
69
104
  threadEpoch: 0,
@@ -96,10 +131,45 @@ export const withThreads =
96
131
  },
97
132
 
98
133
  setAllMessages: (messages: UI_MESSAGE[]) => {
99
- set((state) => ({
100
- ...state,
101
- allMessages: messages,
102
- childrenMap: rebuildMap(messages),
134
+ const state = get();
135
+ const currentVisibleMessages = state.messages;
136
+ const existingTreeMessages = state.allMessages;
137
+ const mergedMessages = mergeTreeMessages(
138
+ messages,
139
+ existingTreeMessages,
140
+ currentVisibleMessages
141
+ );
142
+
143
+ // While the SDK is actively streaming, updating the visible thread with
144
+ // server data would mix the SDK's client-generated message ID with the
145
+ // server's assistantMessageId. The mismatch causes the SDK to push a
146
+ // second assistant message on the next chunk, bumping the epoch and
147
+ // remounting ChatSync mid-stream. Only update the tree index here and
148
+ // let the normal post-stream invalidation apply the full visible update.
149
+ if (state.status === "streaming" || state.status === "submitted") {
150
+ set((prev) => ({
151
+ ...prev,
152
+ allMessages: mergedMessages,
153
+ childrenMap: rebuildMap(mergedMessages),
154
+ }));
155
+ return;
156
+ }
157
+
158
+ const currentLeafId = currentVisibleMessages.at(-1)?.id;
159
+ const nextVisibleThread = currentLeafId
160
+ ? (buildThreadFromLeaf(
161
+ mergedMessages as (UI_MESSAGE & MessageNode)[],
162
+ currentLeafId
163
+ ) as UI_MESSAGE[])
164
+ : currentVisibleMessages;
165
+
166
+ originalSetMessages(nextVisibleThread);
167
+ set((prev) => ({
168
+ ...prev,
169
+ messages: nextVisibleThread,
170
+ threadInitialMessages: nextVisibleThread,
171
+ allMessages: mergedMessages,
172
+ childrenMap: rebuildMap(mergedMessages),
103
173
  }));
104
174
  },
105
175
 
@@ -107,11 +177,11 @@ export const withThreads =
107
177
  set((state) => {
108
178
  const idx = state.allMessages.findIndex((m) => m.id === message.id);
109
179
  let next: UI_MESSAGE[];
110
- if (idx !== -1) {
180
+ if (idx === -1) {
181
+ next = [...state.allMessages, message];
182
+ } else {
111
183
  next = [...state.allMessages];
112
184
  next[idx] = message;
113
- } else {
114
- next = [...state.allMessages, message];
115
185
  }
116
186
  return { ...state, allMessages: next, childrenMap: rebuildMap(next) };
117
187
  });
@@ -135,6 +205,65 @@ export const withThreads =
135
205
  return { siblings, siblingIndex };
136
206
  },
137
207
 
208
+ getParallelGroupInfo: (
209
+ messageId: string
210
+ ): ParallelGroupInfo<UI_MESSAGE> | null => {
211
+ const state = get();
212
+ const message = state.allMessages.find((item) => item.id === messageId);
213
+ if (!message) {
214
+ return null;
215
+ }
216
+
217
+ const metadata = (message as UI_MESSAGE & MessageNode).metadata;
218
+ const parallelGroupId = metadata?.parallelGroupId || null;
219
+ const parentId =
220
+ message.role === "user"
221
+ ? message.id
222
+ : metadata?.parentMessageId || null;
223
+
224
+ if (!(parentId && parallelGroupId)) {
225
+ return null;
226
+ }
227
+
228
+ const groupMessages = (
229
+ (state.childrenMap.get(parentId) ?? []) as UI_MESSAGE[]
230
+ )
231
+ .filter(
232
+ (candidate) =>
233
+ (candidate as UI_MESSAGE & MessageNode).metadata
234
+ ?.parallelGroupId === parallelGroupId
235
+ )
236
+ .sort((a, b) => {
237
+ const aIndex =
238
+ (a as UI_MESSAGE & MessageNode).metadata?.parallelIndex ??
239
+ Number.MAX_SAFE_INTEGER;
240
+ const bIndex =
241
+ (b as UI_MESSAGE & MessageNode).metadata?.parallelIndex ??
242
+ Number.MAX_SAFE_INTEGER;
243
+
244
+ if (aIndex !== bIndex) {
245
+ return aIndex - bIndex;
246
+ }
247
+
248
+ return 0;
249
+ });
250
+
251
+ if (groupMessages.length <= 1) {
252
+ return null;
253
+ }
254
+
255
+ const visibleMessageIds = new Set(state.messages.map((m) => m.id));
256
+ const selectedMessageId =
257
+ groupMessages.find((candidate) => visibleMessageIds.has(candidate.id))
258
+ ?.id ?? null;
259
+
260
+ return {
261
+ messages: groupMessages,
262
+ parallelGroupId,
263
+ selectedMessageId,
264
+ };
265
+ },
266
+
138
267
  switchToSibling: (
139
268
  messageId: string,
140
269
  direction: "prev" | "next"
@@ -170,6 +299,29 @@ export const withThreads =
170
299
  return newThread;
171
300
  },
172
301
 
302
+ switchToMessage: (messageId: string): UI_MESSAGE[] | null => {
303
+ const state = get();
304
+ const { allMessages, childrenMap } = state;
305
+ const message = allMessages.find(
306
+ (candidate) => candidate.id === messageId
307
+ );
308
+ if (!message) {
309
+ return null;
310
+ }
311
+
312
+ const leaf = findLeafDfsToRightFromMessageId(
313
+ childrenMap as Map<string | null, (UI_MESSAGE & MessageNode)[]>,
314
+ messageId
315
+ );
316
+ const newThread = buildThreadFromLeaf(
317
+ allMessages as (UI_MESSAGE & MessageNode)[],
318
+ leaf ? leaf.id : messageId
319
+ ) as UI_MESSAGE[];
320
+
321
+ state.setMessagesWithEpoch(newThread);
322
+ return newThread;
323
+ },
324
+
173
325
  // Override setMessages to auto-bump epoch when thread changes
174
326
  setMessages: (messages: UI_MESSAGE[]) => {
175
327
  const currentMessages = get().messages;
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { StateCreator } from "zustand";
4
4
 
5
- type AnyFn = (...args: any[]) => any;
5
+ type AnyFn = (...args: unknown[]) => unknown;
6
6
 
7
7
  function safeStringifyArgs(args: unknown[]) {
8
8
  try {
@@ -3,6 +3,9 @@ export interface MessageNode {
3
3
  id: string;
4
4
  metadata?: {
5
5
  parentMessageId: string | null;
6
+ parallelGroupId?: string | null;
7
+ parallelIndex?: number | null;
8
+ activeStreamId?: string | null;
6
9
  createdAt: Date;
7
10
  };
8
11
  }
@@ -101,10 +104,26 @@ export function buildChildrenMap<T extends MessageNode>(
101
104
  map.get(parentId)?.push(message);
102
105
  }
103
106
  for (const siblings of map.values()) {
104
- siblings.sort(
105
- (a, b) =>
107
+ siblings.sort((a, b) => {
108
+ const aParallelIndex = a.metadata?.parallelIndex;
109
+ const bParallelIndex = b.metadata?.parallelIndex;
110
+ const sameParallelGroup =
111
+ a.metadata?.parallelGroupId &&
112
+ a.metadata?.parallelGroupId === b.metadata?.parallelGroupId;
113
+
114
+ if (
115
+ sameParallelGroup &&
116
+ typeof aParallelIndex === "number" &&
117
+ typeof bParallelIndex === "number" &&
118
+ aParallelIndex !== bParallelIndex
119
+ ) {
120
+ return aParallelIndex - bParallelIndex;
121
+ }
122
+
123
+ return (
106
124
  toTimestamp(a.metadata?.createdAt) - toTimestamp(b.metadata?.createdAt)
107
- );
125
+ );
126
+ });
108
127
  }
109
128
  return map;
110
129
  }
@@ -135,8 +135,8 @@ export async function replaceFilePartUrlByBinaryDataInMessages(
135
135
  );
136
136
 
137
137
  const mapPart = (
138
- part: TextPart | ImagePart | FilePart | any
139
- ): TextPart | ImagePart | FilePart | any => {
138
+ part: TextPart | ImagePart | FilePart
139
+ ): TextPart | ImagePart | FilePart => {
140
140
  if (part.type === "file") {
141
141
  return mapFilePart(part as FilePart, downloaded);
142
142
  }
@@ -148,14 +148,13 @@ export async function replaceFilePartUrlByBinaryDataInMessages(
148
148
  };
149
149
 
150
150
  return messages.map((message) => {
151
- if (typeof message.content === "string") {
151
+ if (message.role !== "user" || typeof message.content === "string") {
152
152
  return message;
153
153
  }
154
+
154
155
  return {
155
156
  ...message,
156
- content: (
157
- message.content as Array<TextPart | ImagePart | FilePart | any>
158
- ).map(mapPart),
159
- } as ModelMessage;
157
+ content: message.content.map(mapPart),
158
+ };
160
159
  });
161
160
  }
@@ -2,6 +2,12 @@ import "server-only";
2
2
  import { config } from "@/lib/config";
3
3
  import { ANONYMOUS_LIMITS } from "@/lib/types/anonymous";
4
4
 
5
+ interface RedisClient {
6
+ expire(key: string, seconds: number): Promise<unknown>;
7
+ get(key: string): Promise<string | null>;
8
+ incr(key: string): Promise<number>;
9
+ }
10
+
5
11
  interface RateLimitResult {
6
12
  error?: string;
7
13
  remaining: number;
@@ -13,7 +19,7 @@ interface RateLimitOptions {
13
19
  identifier: string;
14
20
  keyPrefix: string;
15
21
  limit: number;
16
- redisClient: any;
22
+ redisClient: RedisClient | null;
17
23
  windowSize: number;
18
24
  }
19
25
 
@@ -82,7 +88,7 @@ const WINDOW_SIZE_MONTH = 30 * 24 * 60 * 60;
82
88
 
83
89
  export async function checkAnonymousRateLimit(
84
90
  ip: string,
85
- redisClient: any
91
+ redisClient: RedisClient | null
86
92
  ): Promise<{
87
93
  success: boolean;
88
94
  error?: string;
@@ -126,7 +132,7 @@ export async function checkAnonymousRateLimit(
126
132
  );
127
133
  return {
128
134
  success: false,
129
- error: `Monthly message limit exceeded. You can make ${RATE_LIMIT.REQUESTS_PER_MONTH} requests per month. You've made ${RATE_LIMIT.REQUESTS_PER_MONTH - monthResult.remaining} requests this month. Try again in ${daysUntilReset} day${daysUntilReset !== 1 ? "s" : ""}.`,
135
+ error: `Monthly message limit exceeded. You can make ${RATE_LIMIT.REQUESTS_PER_MONTH} requests per month. You've made ${RATE_LIMIT.REQUESTS_PER_MONTH - monthResult.remaining} requests this month. Try again in ${daysUntilReset} day${daysUntilReset === 1 ? "" : "s"}.`,
130
136
  headers: {
131
137
  "X-RateLimit-Limit": RATE_LIMIT.REQUESTS_PER_MONTH.toString(),
132
138
  "X-RateLimit-Remaining": monthResult.remaining.toString(),
@@ -11,8 +11,8 @@
11
11
  "analyze": "next experimental-analyze",
12
12
  "start": "next start",
13
13
  "prod": "bun run build && bun run start",
14
- "lint": "bunx ultracite@7.2.3 check",
15
- "format": "bunx ultracite@7.2.3 fix",
14
+ "lint": "bunx ultracite@7.4.3 check",
15
+ "format": "bunx ultracite@7.4.3 fix",
16
16
  "check-env": "bun scripts/check-env.ts",
17
17
  "db:generate": "drizzle-kit generate",
18
18
  "db:migrate": "export VERCEL_ENV=production && bash scripts/with-db.sh bunx tsx lib/db/migrate.ts",
@@ -40,15 +40,17 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@ai-sdk-tools/store": "1.2.0",
43
- "@ai-sdk/anthropic": "^3.0.46",
43
+ "@ai-sdk/anthropic": "^3.0.67",
44
44
  "@ai-sdk/devtools": "^0.0.15",
45
- "@ai-sdk/gateway": "3.0.53",
46
- "@ai-sdk/google": "^3.0.30",
47
- "@ai-sdk/mcp": "^1.0.21",
48
- "@ai-sdk/openai": "^3.0.30",
49
- "@ai-sdk/openai-compatible": "^2.0.30",
50
- "@ai-sdk/provider": "3.0.8",
51
- "@ai-sdk/react": "^3.0.99",
45
+ "@ai-sdk/gateway": "^3.0.92",
46
+ "@ai-sdk/google": "^3.0.59",
47
+ "@ai-sdk/mcp": "^1.0.35",
48
+ "@ai-sdk/openai": "^3.0.51",
49
+ "@ai-sdk/openai-compatible": "^2.0.40",
50
+ "@ai-sdk/provider": "^3.0.8",
51
+ "@ai-sdk/react": "^3.0.152",
52
+ "@better-auth/core": "1.5.6",
53
+ "@better-auth/electron": "^1.5.6",
52
54
  "@codemirror/lang-javascript": "^6.2.2",
53
55
  "@codemirror/lang-python": "^6.1.6",
54
56
  "@codemirror/state": "^6.5.0",
@@ -88,7 +90,7 @@
88
90
  "@radix-ui/react-toggle": "^1.1.10",
89
91
  "@radix-ui/react-tooltip": "^1.2.8",
90
92
  "@radix-ui/react-use-controllable-state": "^1.2.2",
91
- "@streamdown/code": "^1.0.3",
93
+ "@streamdown/code": "^1.1.1",
92
94
  "@streamdown/math": "^1.0.2",
93
95
  "@streamdown/mermaid": "^1.0.2",
94
96
  "@t3-oss/env-nextjs": "^0.13.8",
@@ -104,8 +106,8 @@
104
106
  "@vercel/otel": "^2.1.0",
105
107
  "@vercel/sandbox": "^1.0.2",
106
108
  "@vercel/speed-insights": "^1.3.1",
107
- "ai": "6.0.97",
108
- "better-auth": "^1.4.3",
109
+ "ai": "^6.0.150",
110
+ "better-auth": "^1.5.6",
109
111
  "browser-image-compression": "^2.0.2",
110
112
  "class-variance-authority": "^0.7.1",
111
113
  "clsx": "^2.1.1",
@@ -124,7 +126,7 @@
124
126
  "lucide-react": "0.553.0",
125
127
  "motion": "^12.23.24",
126
128
  "nanoid": "^5.0.8",
127
- "next": "16.1.6",
129
+ "next": "16.2.0",
128
130
  "next-themes": "^0.4.6",
129
131
  "nuqs": "^2.8.5",
130
132
  "papaparse": "^5.5.2",
@@ -142,7 +144,7 @@
142
144
  "server-only": "^0.0.1",
143
145
  "shiki": "^3.19.0",
144
146
  "sonner": "^2.0.7",
145
- "streamdown": "^2.2.0",
147
+ "streamdown": "^2.5.0",
146
148
  "superjson": "^2.2.2",
147
149
  "tailwind-merge": "^3.3.1",
148
150
  "throttleit": "^2.1.0",
@@ -162,7 +164,7 @@
162
164
  }
163
165
  },
164
166
  "devDependencies": {
165
- "@biomejs/biome": "^2.2.4",
167
+ "@biomejs/biome": "2.4.10",
166
168
  "@playwright/test": "^1.50.1",
167
169
  "@tailwindcss/postcss": "^4.1.12",
168
170
  "@tailwindcss/typography": "^0.5.19",
@@ -178,8 +180,8 @@
178
180
  "tailwindcss": "^4.1.12",
179
181
  "tsx": "^4.19.1",
180
182
  "tw-animate-css": "^1.3.7",
181
- "typescript": "5.8.3",
182
- "ultracite": "7.2.3",
183
+ "typescript": "6.0.2",
184
+ "ultracite": "7.4.3",
183
185
  "vite-tsconfig-paths": "^6.1.1",
184
186
  "vitest": "^4.0.0"
185
187
  }
@@ -51,44 +51,25 @@ export default defineConfig({
51
51
 
52
52
  /* Configure projects */
53
53
  projects: [
54
- {
55
- name: "setup:auth",
56
- testMatch: /auth.setup.e2e.ts/,
57
- },
58
- {
59
- name: "setup:reasoning",
60
- testMatch: /reasoning.setup.e2e.ts/,
61
- dependencies: ["setup:auth"],
62
- use: {
63
- ...devices["Desktop Chrome"],
64
- storageState: "playwright/.auth/session.json",
65
- },
66
- },
67
54
  {
68
55
  name: "chat",
69
56
  testMatch: /chat.e2e.ts/,
70
- dependencies: ["setup:auth"],
71
57
  use: {
72
58
  ...devices["Desktop Chrome"],
73
- storageState: "playwright/.auth/session.json",
74
59
  },
75
60
  },
76
61
  {
77
62
  name: "reasoning",
78
63
  testMatch: /reasoning.e2e.ts/,
79
- dependencies: ["setup:reasoning"],
80
64
  use: {
81
65
  ...devices["Desktop Chrome"],
82
- storageState: "playwright/.reasoning/session.json",
83
66
  },
84
67
  },
85
68
  {
86
69
  name: "artifacts",
87
70
  testMatch: /artifacts.e2e.ts/,
88
- dependencies: ["setup:auth"],
89
71
  use: {
90
72
  ...devices["Desktop Chrome"],
91
- storageState: "playwright/.auth/session.json",
92
73
  },
93
74
  },
94
75
 
@@ -13,7 +13,12 @@ import React, {
13
13
  } from "react";
14
14
  import type { LexicalChatInputRef } from "@/components/lexical-chat-input";
15
15
  import type { AppModelId } from "@/lib/ai/app-models";
16
- import type { Attachment, UiToolName } from "@/lib/ai/types";
16
+ import {
17
+ type Attachment,
18
+ getPrimarySelectedModelId,
19
+ type SelectedModelValue,
20
+ type UiToolName,
21
+ } from "@/lib/ai/types";
17
22
  import { useChatModels } from "./chat-models-provider";
18
23
  import { useDefaultModel, useModelChange } from "./default-model-provider";
19
24
 
@@ -24,10 +29,12 @@ interface ChatInputContextType {
24
29
  getInputValue: () => string;
25
30
  handleInputChange: (value: string) => void;
26
31
  handleModelChange: (modelId: AppModelId) => Promise<void>;
32
+ handleModelSelectionChange: (selection: SelectedModelValue) => Promise<void>;
27
33
  handleSubmit: (submitFn: () => void, isEditMode?: boolean) => void;
28
34
  isEmpty: boolean;
29
35
  isProjectContext: boolean;
30
36
  selectedModelId: AppModelId;
37
+ selectedModelSelection: SelectedModelValue;
31
38
  selectedTool: UiToolName | null;
32
39
  setAttachments: Dispatch<SetStateAction<Attachment[]>>;
33
40
  setSelectedTool: Dispatch<SetStateAction<UiToolName | null>>;
@@ -45,6 +52,7 @@ interface ChatInputProviderProps {
45
52
  isProjectContext?: boolean;
46
53
  localStorageEnabled?: boolean;
47
54
  overrideModelId?: AppModelId; // For message editing where we want to use the original model
55
+ overrideModelSelection?: SelectedModelValue; // For message editing with multi-model selection
48
56
  }
49
57
 
50
58
  export function ChatInputProvider({
@@ -53,6 +61,7 @@ export function ChatInputProvider({
53
61
  initialTool = null,
54
62
  initialAttachments = [],
55
63
  overrideModelId,
64
+ overrideModelSelection,
56
65
  localStorageEnabled = true,
57
66
  isProjectContext = false,
58
67
  }: ChatInputProviderProps) {
@@ -95,6 +104,10 @@ export function ChatInputProvider({
95
104
  const [selectedModelId, setSelectedModelId] = useState<AppModelId>(
96
105
  overrideModelId || defaultModel
97
106
  );
107
+ const [selectedModelSelection, setSelectedModelSelection] =
108
+ useState<SelectedModelValue>(
109
+ overrideModelSelection ?? overrideModelId ?? defaultModel
110
+ );
98
111
 
99
112
  // IMPORTANT: do not read localStorage during initial render.
100
113
  // Next SSRs client components; localStorage is client-only and will cause hydration mismatches
@@ -128,7 +141,7 @@ export function ChatInputProvider({
128
141
 
129
142
  const { getModelById } = useChatModels();
130
143
 
131
- const handleModelChange = useCallback(
144
+ const persistPrimaryModelChange = useCallback(
132
145
  async (modelId: AppModelId) => {
133
146
  const modelDef = getModelById(modelId);
134
147
 
@@ -146,6 +159,29 @@ export function ChatInputProvider({
146
159
  [selectedTool, changeModel, getModelById]
147
160
  );
148
161
 
162
+ const handleModelChange = useCallback(
163
+ async (modelId: AppModelId) => {
164
+ setSelectedModelSelection(modelId);
165
+ await persistPrimaryModelChange(modelId);
166
+ },
167
+ [persistPrimaryModelChange]
168
+ );
169
+
170
+ const handleModelSelectionChange = useCallback(
171
+ async (selection: SelectedModelValue) => {
172
+ setSelectedModelSelection(selection);
173
+
174
+ const primaryModelId = getPrimarySelectedModelId(selection);
175
+
176
+ if (!primaryModelId) {
177
+ return;
178
+ }
179
+
180
+ await persistPrimaryModelChange(primaryModelId);
181
+ },
182
+ [persistPrimaryModelChange]
183
+ );
184
+
149
185
  const clearInput = useCallback(() => {
150
186
  editorRef.current?.clear();
151
187
  setLocalStorageInput("");
@@ -207,7 +243,9 @@ export function ChatInputProvider({
207
243
  attachments,
208
244
  setAttachments,
209
245
  selectedModelId,
246
+ selectedModelSelection,
210
247
  handleModelChange,
248
+ handleModelSelectionChange,
211
249
  getInputValue,
212
250
  handleInputChange,
213
251
  getInitialInput,
@@ -1,6 +1,8 @@
1
1
  import type { NextRequest } from "next/server";
2
2
  import { NextResponse } from "next/server";
3
3
  import { auth } from "@/lib/auth";
4
+ import { config as appConfig } from "@/lib/config";
5
+ import { isPlaywrightTestEnvironment } from "@/lib/constants";
4
6
 
5
7
  function isPublicApiRoute(pathname: string): boolean {
6
8
  return (
@@ -33,7 +35,23 @@ function isPublicPage(pathname: string): boolean {
33
35
  }
34
36
 
35
37
  function isAuthPage(pathname: string): boolean {
36
- return pathname.startsWith("/login") || pathname.startsWith("/register");
38
+ return (
39
+ pathname.startsWith("/login") ||
40
+ pathname.startsWith("/register") ||
41
+ isDeviceLoginPage(pathname)
42
+ );
43
+ }
44
+
45
+ function isDeviceLoginPage(pathname: string): boolean {
46
+ return appConfig.desktopApp.enabled && pathname.startsWith("/device-login");
47
+ }
48
+
49
+ function getSafeReturnTo(url: URL): string | null {
50
+ const returnTo = url.searchParams.get("returnTo");
51
+ if (!returnTo?.startsWith("/") || returnTo.startsWith("//")) {
52
+ return null;
53
+ }
54
+ return returnTo;
37
55
  }
38
56
 
39
57
  export async function proxy(req: NextRequest) {
@@ -44,11 +62,18 @@ export async function proxy(req: NextRequest) {
44
62
  return;
45
63
  }
46
64
 
65
+ if (isPlaywrightTestEnvironment) {
66
+ // Playwright CI runs the app anonymously and should never reach session I/O.
67
+ return;
68
+ }
69
+
47
70
  const session = await auth.api.getSession({ headers: req.headers });
48
71
  const isLoggedIn = !!session?.user;
72
+ const isDeviceLoginRoute = isDeviceLoginPage(pathname);
73
+ const returnTo = getSafeReturnTo(url);
49
74
 
50
- if (isLoggedIn && isAuthPage(pathname)) {
51
- return NextResponse.redirect(new URL("/", url));
75
+ if (isLoggedIn && isAuthPage(pathname) && !isDeviceLoginRoute) {
76
+ return NextResponse.redirect(new URL(returnTo ?? "/", url));
52
77
  }
53
78
 
54
79
  if (isAuthPage(pathname) || isPublicPage(pathname)) {
@@ -16,6 +16,7 @@ import {
16
16
  getMissingRequirement,
17
17
  isRequirementSatisfied,
18
18
  } from "../lib/config-requirements";
19
+ import { isPlaywrightTestEnvironment } from "../lib/playwright-test-environment";
19
20
 
20
21
  interface ValidationError {
21
22
  feature: string;
@@ -158,6 +159,15 @@ function checkGatewaySnapshot(): string | null {
158
159
 
159
160
  function checkEnv(): void {
160
161
  const env = process.env;
162
+ if (isPlaywrightTestEnvironment(env)) {
163
+ console.log(
164
+ "✅ Skipping optional environment validation in Playwright test mode"
165
+ );
166
+ // Playwright CI only exercises anonymous flows, so optional feature checks
167
+ // and the gateway snapshot warning stay enforced in non-Playwright builds.
168
+ return;
169
+ }
170
+
161
171
  const baseUrlError = validateBaseUrl(env);
162
172
  const errors = [
163
173
  ...(baseUrlError ? [baseUrlError] : []),
@@ -2,7 +2,13 @@
2
2
  set -e
3
3
 
4
4
  BRANCH_NAME="${1:-dev-local}"
5
- BRANCH_FILE=".neon-branch"
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ MONOREPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
7
+ if [ -f "$MONOREPO_ROOT/turbo.json" ]; then
8
+ BRANCH_FILE="$MONOREPO_ROOT/.neon-branch"
9
+ else
10
+ BRANCH_FILE="$(cd "$SCRIPT_DIR/.." && pwd)/.neon-branch"
11
+ fi
6
12
 
7
13
  # Check if we're currently on this branch
8
14
  if [ -f "$BRANCH_FILE" ] && [ "$(cat "$BRANCH_FILE")" = "$BRANCH_NAME" ]; then