birdclaw 0.8.1 → 0.8.2

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.
package/src/lib/sqlite.ts CHANGED
@@ -5,6 +5,7 @@ export type Database = NativeSqliteDatabase;
5
5
  type DatabaseOptions = {
6
6
  readonly?: boolean;
7
7
  fileMustExist?: boolean;
8
+ timeout?: number;
8
9
  };
9
10
 
10
11
  type PragmaOptions = {
@@ -16,6 +17,8 @@ type RunResult = {
16
17
  lastInsertRowid: number;
17
18
  };
18
19
 
20
+ export const SQLITE_BUSY_TIMEOUT_MS = 30_000;
21
+
19
22
  function bindArgs(parameters: unknown[]) {
20
23
  if (parameters.length === 1 && Array.isArray(parameters[0])) {
21
24
  return parameters[0];
@@ -87,7 +90,7 @@ export class NativeSqliteDatabase {
87
90
  constructor(path: string, options: DatabaseOptions = {}) {
88
91
  this.db = new DatabaseSync(path, {
89
92
  readOnly: options.readonly,
90
- timeout: 5000,
93
+ timeout: options.timeout ?? SQLITE_BUSY_TIMEOUT_MS,
91
94
  });
92
95
  }
93
96
 
@@ -123,7 +126,7 @@ export class NativeSqliteDatabase {
123
126
  return (...args: TArgs) => {
124
127
  const nested = this.db.isTransaction;
125
128
  const savepoint = `__birdclaw_tx_${++this.transactionDepth}`;
126
- this.exec(nested ? `savepoint ${savepoint}` : "begin");
129
+ this.exec(nested ? `savepoint ${savepoint}` : "begin immediate");
127
130
  try {
128
131
  const result = fn(...args);
129
132
  this.exec(nested ? `release ${savepoint}` : "commit");
@@ -8,6 +8,7 @@ import { getNativeDb } from "./db";
8
8
  import { runEffectPromise, tryPromise } from "./effect-runtime";
9
9
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
10
10
  import { readSyncCache, writeSyncCache } from "./sync-cache";
11
+ import { tweetEntitiesFromXurl } from "./tweet-render";
11
12
  import type {
12
13
  XurlMentionData,
13
14
  XurlMentionsResponse,
@@ -310,7 +311,7 @@ function mergeTimelineCollectionIntoLocalStore(
310
311
  countTweetMedia(tweet),
311
312
  bookmarked,
312
313
  liked,
313
- JSON.stringify(tweet.entities ?? {}),
314
+ JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
314
315
  buildMediaJsonFromIncludes(tweet, payload.includes?.media),
315
316
  quotedTweetId,
316
317
  );
@@ -5,6 +5,7 @@ import { getNativeDb } from "./db";
5
5
  import { runEffectPromise } from "./effect-runtime";
6
6
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
7
7
  import { readSyncCache, writeSyncCache } from "./sync-cache";
8
+ import { tweetEntitiesFromXurl } from "./tweet-render";
8
9
  import type {
9
10
  XurlMediaItem,
10
11
  XurlMentionUser,
@@ -244,7 +245,7 @@ function mergeHomeTimelineIntoLocalStore(
244
245
  replyToId,
245
246
  Number(tweet.public_metrics?.like_count ?? 0),
246
247
  countTweetMedia(tweet),
247
- JSON.stringify(tweet.entities ?? {}),
248
+ JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
248
249
  buildMediaJsonFromIncludes(tweet, payload.includes?.media),
249
250
  quotedTweetId,
250
251
  );
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ TweetArticle,
2
3
  TweetEntities,
3
4
  TweetHashtagEntity,
4
5
  TweetMentionEntity,
@@ -57,6 +58,34 @@ export function displayUrlForLink(url: string) {
57
58
  }
58
59
  }
59
60
 
61
+ function comparableUrl(value: string) {
62
+ try {
63
+ const parsed = new URL(value);
64
+ return `${parsed.protocol}//${parsed.hostname.replace(/^www\./, "")}${parsed.pathname}`;
65
+ } catch {
66
+ return value.split("?")[0] ?? value;
67
+ }
68
+ }
69
+
70
+ export function isTweetArticleUrlEntity(
71
+ entry: TweetUrlEntity,
72
+ article: TweetArticle,
73
+ ) {
74
+ if (comparableUrl(entry.expandedUrl) === comparableUrl(article.url)) {
75
+ return true;
76
+ }
77
+ try {
78
+ const parsed = new URL(entry.expandedUrl);
79
+ const host = parsed.hostname.replace(/^www\./, "");
80
+ return (
81
+ (host === "x.com" || host === "twitter.com") &&
82
+ parsed.pathname.startsWith("/i/article/")
83
+ );
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
60
89
  function asRecord(value: unknown) {
61
90
  return value && typeof value === "object"
62
91
  ? (value as Record<string, unknown>)
@@ -68,6 +97,9 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
68
97
  const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
69
98
  const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
70
99
  const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
100
+ const rawArticle = asRecord(entities.article);
101
+ const articleTitle = String(rawArticle.title ?? "").trim();
102
+ const articleUrl = String(rawArticle.url ?? "").trim();
71
103
 
72
104
  return {
73
105
  ...(rawMentions.length
@@ -102,6 +134,23 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
102
134
  ),
103
135
  start: Number(value.start ?? 0),
104
136
  end: Number(value.end ?? 0),
137
+ ...(typeof value.title === "string"
138
+ ? { title: value.title }
139
+ : {}),
140
+ ...(typeof value.description === "string" ||
141
+ value.description === null
142
+ ? { description: value.description }
143
+ : {}),
144
+ ...(typeof value.imageUrl === "string"
145
+ ? { imageUrl: value.imageUrl }
146
+ : typeof value.image_url === "string"
147
+ ? { imageUrl: value.image_url }
148
+ : {}),
149
+ ...(typeof value.siteName === "string"
150
+ ? { siteName: value.siteName }
151
+ : typeof value.site_name === "string"
152
+ ? { siteName: value.site_name }
153
+ : {}),
105
154
  };
106
155
  }),
107
156
  }
@@ -118,6 +167,35 @@ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
118
167
  }),
119
168
  }
120
169
  : {}),
170
+ ...(articleTitle && articleUrl
171
+ ? {
172
+ article: {
173
+ title: articleTitle,
174
+ url: articleUrl,
175
+ ...(typeof (rawArticle.previewText ?? rawArticle.preview_text) ===
176
+ "string" &&
177
+ String(rawArticle.previewText ?? rawArticle.preview_text).trim()
178
+ ? {
179
+ previewText: String(
180
+ rawArticle.previewText ?? rawArticle.preview_text,
181
+ ).trim(),
182
+ }
183
+ : {}),
184
+ ...(typeof (
185
+ rawArticle.coverImageUrl ?? rawArticle.cover_image_url
186
+ ) === "string" &&
187
+ String(
188
+ rawArticle.coverImageUrl ?? rawArticle.cover_image_url,
189
+ ).trim()
190
+ ? {
191
+ coverImageUrl: String(
192
+ rawArticle.coverImageUrl ?? rawArticle.cover_image_url,
193
+ ).trim(),
194
+ }
195
+ : {}),
196
+ },
197
+ }
198
+ : {}),
121
199
  };
122
200
  }
123
201
 
@@ -5,6 +5,7 @@ import { getNativeDb } from "./db";
5
5
  import { runEffectPromise } from "./effect-runtime";
6
6
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
7
7
  import { readSyncCache, writeSyncCache } from "./sync-cache";
8
+ import { tweetEntitiesFromXurl } from "./tweet-render";
8
9
  import type { XurlMentionsResponse, XurlTweetsResponse } from "./types";
9
10
  import { upsertTweetAccountEdge } from "./tweet-account-edges";
10
11
  import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
@@ -190,7 +191,7 @@ function mergeTweetSearchIntoLocalStore(
190
191
  replyToId,
191
192
  Number(tweet.public_metrics?.like_count ?? 0),
192
193
  countTweetMedia(tweet),
193
- JSON.stringify(tweet.entities ?? {}),
194
+ JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
194
195
  buildMediaJsonFromIncludes(tweet, payload.includes?.media),
195
196
  quotedTweetId,
196
197
  );
package/src/lib/types.ts CHANGED
@@ -101,10 +101,18 @@ export interface TweetHashtagEntity {
101
101
  end: number;
102
102
  }
103
103
 
104
+ export interface TweetArticle {
105
+ title: string;
106
+ previewText?: string;
107
+ url: string;
108
+ coverImageUrl?: string;
109
+ }
110
+
104
111
  export interface TweetEntities {
105
112
  mentions?: TweetMentionEntity[];
106
113
  urls?: TweetUrlEntity[];
107
114
  hashtags?: TweetHashtagEntity[];
115
+ article?: TweetArticle;
108
116
  }
109
117
 
110
118
  export interface TweetMediaItem {
package/src/lib/ui.ts CHANGED
@@ -311,7 +311,7 @@ export const profilePreviewTriggerClass =
311
311
  "profile-preview-trigger inline-flex text-inherit";
312
312
 
313
313
  export const profilePreviewCardClass =
314
- "pointer-events-none absolute left-0 z-30 grid w-[280px] gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 opacity-0 shadow-[0_8px_28px_var(--shadow-strong)] transition-all duration-150 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
314
+ "fixed z-40 w-[280px] overflow-y-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 shadow-[0_8px_28px_var(--shadow-strong)]";
315
315
 
316
316
  export const profilePreviewHeaderClass = "flex items-center gap-3";
317
317
 
package/src/router.tsx CHANGED
@@ -7,7 +7,7 @@ export function getRouter() {
7
7
 
8
8
  scrollRestoration: true,
9
9
  defaultPreload: "intent",
10
- defaultPreloadStaleTime: 0,
10
+ defaultPreloadStaleTime: 60_000,
11
11
  });
12
12
 
13
13
  return router;
@@ -1,11 +1,9 @@
1
- import { TanStackDevtools } from "@tanstack/react-devtools";
2
1
  import {
3
2
  createRootRoute,
4
3
  HeadContent,
5
4
  Scripts,
6
5
  useRouterState,
7
6
  } from "@tanstack/react-router";
8
- import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
9
7
  import type { ReactNode } from "react";
10
8
  import { AppNav } from "#/components/AppNav";
11
9
  import { ThemeProvider, themeScript } from "#/lib/theme";
@@ -74,17 +72,6 @@ function RootDocument({ children }: { children: ReactNode }) {
74
72
  </main>
75
73
  </div>
76
74
  </ThemeProvider>
77
- <TanStackDevtools
78
- config={{
79
- position: "bottom-right",
80
- }}
81
- plugins={[
82
- {
83
- name: "Tanstack Router",
84
- render: <TanStackRouterDevtoolsPanel />,
85
- },
86
- ]}
87
- />
88
75
  <Scripts />
89
76
  </body>
90
77
  </html>
@@ -18,7 +18,9 @@ export const Route = createFileRoute("/api/status")({
18
18
  if (denied) return denied;
19
19
 
20
20
  yield* maybeAutoUpdateBackupEffect();
21
- return jsonResponse(yield* getQueryEnvelopeEffect());
21
+ return jsonResponse(
22
+ yield* getQueryEnvelopeEffect({ includeArchives: false }),
23
+ );
22
24
  }),
23
25
  ),
24
26
  },
@@ -6,9 +6,11 @@ import { FeedEmpty, FeedError, FeedLoading } from "#/components/FeedState";
6
6
  import { SyncNowButton } from "#/components/SyncNowButton";
7
7
  import { useSelectedAccountId } from "#/components/account-selection";
8
8
  import {
9
+ fetchCachedQueryResponse,
9
10
  fetchQueryEnvelope,
10
- fetchQueryResponse,
11
+ invalidateCachedQueryResponses,
11
12
  postAction,
13
+ readCachedQueryResponse,
12
14
  } from "#/lib/api-client";
13
15
  import type {
14
16
  DmConversationItem,
@@ -16,6 +18,7 @@ import type {
16
18
  QueryEnvelope,
17
19
  ReplyFilter,
18
20
  } from "#/lib/types";
21
+ import { useDebouncedValue } from "#/components/useDebouncedValue";
19
22
  import {
20
23
  cx,
21
24
  pageHeaderClass,
@@ -83,9 +86,10 @@ function DmsRoute() {
83
86
  const [error, setError] = useState<string | null>(null);
84
87
  const [replyError, setReplyError] = useState<string | null>(null);
85
88
  const selectedAccountId = useSelectedAccountId(meta?.accounts);
89
+ const debouncedSearch = useDebouncedValue(search, 180);
86
90
 
87
- async function loadStatus() {
88
- setMeta(await fetchQueryEnvelope());
91
+ async function loadStatus(force = false) {
92
+ setMeta(await fetchQueryEnvelope(undefined, { force }));
89
93
  }
90
94
 
91
95
  useEffect(() => {
@@ -113,29 +117,41 @@ function DmsRoute() {
113
117
  if (selectedConversationId) {
114
118
  url.searchParams.set("conversationId", selectedConversationId);
115
119
  }
116
- if (search.trim()) {
117
- url.searchParams.set("search", search.trim());
120
+ if (debouncedSearch.trim()) {
121
+ url.searchParams.set("search", debouncedSearch.trim());
118
122
  }
119
123
 
124
+ const applyResponse = (
125
+ data: Awaited<ReturnType<typeof fetchCachedQueryResponse>>,
126
+ ) => {
127
+ const conversations = data.items as DmConversationItem[];
128
+ const nextSelected =
129
+ data.selectedConversation?.conversation.id ?? conversations[0]?.id;
130
+ setLoadedConversationId(data.selectedConversation?.conversation.id);
131
+ setItems(conversations);
132
+ setSelectedConversationId((current) => {
133
+ if (!current) return nextSelected;
134
+ return conversations.some((conversation) => conversation.id === current)
135
+ ? current
136
+ : nextSelected;
137
+ });
138
+ setMessages(data.selectedConversation?.messages ?? []);
139
+ };
120
140
  setError(null);
141
+ const cached = readCachedQueryResponse(url);
142
+ if (cached) {
143
+ applyResponse(cached);
144
+ setLoading(false);
145
+ return () => {
146
+ active = false;
147
+ controller.abort();
148
+ };
149
+ }
121
150
  setLoading(true);
122
- fetchQueryResponse(url, { signal: controller.signal })
151
+ fetchCachedQueryResponse(url, { signal: controller.signal })
123
152
  .then((data) => {
124
153
  if (!active) return;
125
- const conversations = data.items as DmConversationItem[];
126
- const nextSelected =
127
- data.selectedConversation?.conversation.id ?? conversations[0]?.id;
128
- setLoadedConversationId(data.selectedConversation?.conversation.id);
129
- setItems(conversations);
130
- setSelectedConversationId((current) => {
131
- if (!current) return nextSelected;
132
- return conversations.some(
133
- (conversation) => conversation.id === current,
134
- )
135
- ? current
136
- : nextSelected;
137
- });
138
- setMessages(data.selectedConversation?.messages ?? []);
154
+ applyResponse(data);
139
155
  })
140
156
  .catch((fetchError: unknown) => {
141
157
  if (
@@ -170,7 +186,7 @@ function DmsRoute() {
170
186
  inboxFilter,
171
187
  refreshTick,
172
188
  replyFilter,
173
- search,
189
+ debouncedSearch,
174
190
  selectedConversationId,
175
191
  selectedAccountId,
176
192
  sort,
@@ -247,6 +263,7 @@ function DmsRoute() {
247
263
  text,
248
264
  });
249
265
 
266
+ invalidateCachedQueryResponses();
250
267
  setSelectedConversationId(conversationId);
251
268
  setRefreshTick((value) => value + 1);
252
269
  } catch (error) {
@@ -258,8 +275,9 @@ function DmsRoute() {
258
275
  }
259
276
 
260
277
  function refreshLocalView() {
278
+ invalidateCachedQueryResponses();
261
279
  setRefreshTick((value) => value + 1);
262
- void loadStatus();
280
+ void loadStatus(true);
263
281
  }
264
282
 
265
283
  return (
@@ -19,6 +19,11 @@ import {
19
19
  } from "#/components/FeedState";
20
20
  import { ProfilePreview } from "#/components/ProfilePreview";
21
21
  import { SmartTimestamp } from "#/components/SmartTimestamp";
22
+ import {
23
+ loadClientCache,
24
+ readClientCache,
25
+ writeClientCache,
26
+ } from "#/lib/client-cache";
22
27
  import { formatCompactNumber } from "#/lib/present";
23
28
  import type {
24
29
  LinkInsightItem,
@@ -56,6 +61,10 @@ const LINK_INSIGHTS_LIMIT = 30;
56
61
  const LINK_INSIGHTS_COMMENTS_LIMIT = 30;
57
62
  const PROFILE_HYDRATION_LIMIT = 30;
58
63
  const PROFILE_HYDRATION_DELAY_MS = 1200;
64
+ const LINK_INSIGHTS_CACHE_PREFIX = "link-insights:";
65
+ const LINK_INSIGHTS_CACHE_MAX_AGE_MS = 5 * 60_000;
66
+ const LINK_PROFILE_HYDRATED_CACHE_PREFIX = "link-profile-hydrated:";
67
+ const hydratingLinkProfileHandles = new Set<string>();
59
68
 
60
69
  const ranges: Array<{ value: LinkInsightRange; label: string }> = [
61
70
  { value: "today", label: "Today" },
@@ -87,6 +96,19 @@ function insightCacheKey(
87
96
  return `${kind}:${range}:${sort}:${source}:${refreshTick}`;
88
97
  }
89
98
 
99
+ function sharedInsightCacheKey(
100
+ kind: LinkInsightKind,
101
+ range: LinkInsightRange,
102
+ sort: LinkInsightSort,
103
+ source: LinkInsightSource,
104
+ ) {
105
+ return `${LINK_INSIGHTS_CACHE_PREFIX}${kind}:${range}:${sort}:${source}`;
106
+ }
107
+
108
+ function hydratedProfileCacheKey(handle: string) {
109
+ return `${LINK_PROFILE_HYDRATED_CACHE_PREFIX}${handle.toLowerCase()}`;
110
+ }
111
+
90
112
  function linkInsightsUrl(
91
113
  kind: LinkInsightKind,
92
114
  range: LinkInsightRange,
@@ -651,8 +673,16 @@ function LinksRoute() {
651
673
  const [sort, setSort] = useState<LinkInsightSort>("rank");
652
674
  const [search, setSearch] = useState("");
653
675
  const [refreshTick, setRefreshTick] = useState(0);
654
- const hydratedHandlesRef = useRef(new Set<string>());
655
- const cacheRef = useRef(new Map<string, LinkInsightResponse>());
676
+ const initialCacheKey = insightCacheKey(kind, range, sort, source, 0);
677
+ const initialSharedData = readClientCache<LinkInsightResponse>(
678
+ sharedInsightCacheKey(kind, range, sort, source),
679
+ LINK_INSIGHTS_CACHE_MAX_AGE_MS,
680
+ );
681
+ const cacheRef = useRef(
682
+ new Map<string, LinkInsightResponse>(
683
+ initialSharedData ? [[initialCacheKey, initialSharedData]] : [],
684
+ ),
685
+ );
656
686
  const inFlightRef = useRef(new Set<string>());
657
687
  const mountedRef = useRef(true);
658
688
  const [errorByKey, setErrorByKey] = useState<Record<string, string>>({});
@@ -680,14 +710,35 @@ function LinksRoute() {
680
710
  if (cacheRef.current.has(key) || inFlightRef.current.has(key)) {
681
711
  return;
682
712
  }
713
+ const sharedKey = sharedInsightCacheKey(fetchKind, range, sort, source);
714
+ if (refreshTick === 0) {
715
+ const cached = readClientCache<LinkInsightResponse>(
716
+ sharedKey,
717
+ LINK_INSIGHTS_CACHE_MAX_AGE_MS,
718
+ );
719
+ if (cached) {
720
+ cacheRef.current.set(key, cached);
721
+ bumpCacheVersion((value) => value + 1);
722
+ return;
723
+ }
724
+ }
683
725
  inFlightRef.current.add(key);
684
726
  setErrorByKey((current) => {
685
727
  const rest = { ...current };
686
728
  delete rest[key];
687
729
  return rest;
688
730
  });
689
- fetch(linkInsightsUrl(fetchKind, range, sort, source, refreshTick))
690
- .then((response) => response.json())
731
+ loadClientCache(
732
+ sharedKey,
733
+ () =>
734
+ fetch(
735
+ linkInsightsUrl(fetchKind, range, sort, source, refreshTick),
736
+ ).then((response) => response.json() as Promise<LinkInsightResponse>),
737
+ {
738
+ force: refreshTick > 0,
739
+ maxAgeMs: LINK_INSIGHTS_CACHE_MAX_AGE_MS,
740
+ },
741
+ )
691
742
  .then((response: LinkInsightResponse) => {
692
743
  cacheRef.current.set(key, response);
693
744
  if (mountedRef.current) {
@@ -732,7 +783,9 @@ function LinksRoute() {
732
783
 
733
784
  useEffect(() => {
734
785
  const handles = collectProfilesForHydration(data).filter(
735
- (handle) => !hydratedHandlesRef.current.has(handle.toLowerCase()),
786
+ (handle) =>
787
+ !readClientCache<boolean>(hydratedProfileCacheKey(handle)) &&
788
+ !hydratingLinkProfileHandles.has(handle.toLowerCase()),
736
789
  );
737
790
  if (handles.length === 0) {
738
791
  return;
@@ -742,19 +795,30 @@ function LinksRoute() {
742
795
  const url = new URL("/api/profile-hydrate", window.location.origin);
743
796
  url.searchParams.set("handles", handles.join(","));
744
797
  for (const handle of handles) {
745
- hydratedHandlesRef.current.add(handle.toLowerCase());
798
+ hydratingLinkProfileHandles.add(handle.toLowerCase());
746
799
  }
800
+ const finishHydration = (succeeded: boolean) => {
801
+ for (const handle of handles) {
802
+ const normalized = handle.toLowerCase();
803
+ hydratingLinkProfileHandles.delete(normalized);
804
+ if (succeeded) {
805
+ writeClientCache(hydratedProfileCacheKey(normalized), true);
806
+ }
807
+ }
808
+ };
747
809
 
748
810
  let idleId: number | null = null;
749
811
  const runHydration = () => {
750
812
  fetch(url, { signal: controller.signal })
751
813
  .then((response) => response.json())
752
814
  .then((response: { hydratedProfiles?: number }) => {
815
+ finishHydration(true);
753
816
  if ((response.hydratedProfiles ?? 0) > 0) {
754
817
  setRefreshTick((value) => value + 1);
755
818
  }
756
819
  })
757
820
  .catch((error: unknown) => {
821
+ finishHydration(false);
758
822
  if (error instanceof DOMException && error.name === "AbortError") {
759
823
  return;
760
824
  }
@@ -771,6 +835,7 @@ function LinksRoute() {
771
835
 
772
836
  return () => {
773
837
  controller.abort();
838
+ finishHydration(false);
774
839
  window.clearTimeout(timer);
775
840
  if (idleId !== null && "cancelIdleCallback" in window) {
776
841
  window.cancelIdleCallback(idleId);
package/vite.config.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import tailwindcss from "@tailwindcss/vite";
2
- import { devtools } from "@tanstack/devtools-vite";
3
2
  import { tanstackStart } from "@tanstack/react-start/plugin/vite";
4
3
  import viteReact from "@vitejs/plugin-react";
5
4
  import { defineConfig } from "vite";
@@ -11,7 +10,6 @@ const extraAllowedHosts =
11
10
 
12
11
  const config = defineConfig({
13
12
  plugins: [
14
- devtools(),
15
13
  tailwindcss(),
16
14
  tanstackStart({
17
15
  router: {