deepspace 0.6.2 → 0.7.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.
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import {
10
+ memo,
10
11
  useState,
11
12
  useRef,
12
13
  useEffect,
@@ -179,6 +180,12 @@ function useStreamingChat(
179
180
  if (isLoadingRef.current) return
180
181
  setError(null)
181
182
  lastSendRef.current = { content }
183
+ // Claim the in-flight slot BEFORE any await. The auto-create below is a
184
+ // real network round-trip; setting the guard only after it would let a
185
+ // second send() slip through with chatId still null and create a second
186
+ // chat (double-create race, orphaning the first turn).
187
+ isLoadingRef.current = true
188
+ setIsLoading(true)
182
189
 
183
190
  let activeChatId = chatId
184
191
  if (!activeChatId) {
@@ -198,6 +205,8 @@ function useStreamingChat(
198
205
  onChatCreated?.(activeChatId)
199
206
  } catch (err) {
200
207
  setError(err instanceof Error ? err : new Error(String(err)))
208
+ isLoadingRef.current = false
209
+ setIsLoading(false)
201
210
  return
202
211
  }
203
212
  }
@@ -209,8 +218,6 @@ function useStreamingChat(
209
218
  const userMsg: InFlightMessage = { id: userMessageId, role: 'user', content, parts: [], forChatId: activeChatId }
210
219
  const asstMsg: InFlightMessage = { id: assistantId, role: 'assistant', content: '', parts: [], forChatId: activeChatId }
211
220
  setInFlight([userMsg, asstMsg])
212
- isLoadingRef.current = true
213
- setIsLoading(true)
214
221
  abortRef.current = new AbortController()
215
222
 
216
223
  try {
@@ -506,15 +513,24 @@ export function ChatPanel({
506
513
  orderDir: 'asc',
507
514
  })
508
515
 
509
- const messages: RenderMessage[] = useMemo(() => {
510
- const persisted: RenderMessage[] = persistedRecords.map((r) => ({
511
- id: r.recordId,
512
- role: r.data.role,
513
- content: r.data.content ?? '',
514
- parts: r.data.parts,
515
- }))
516
- const persistedIds = new Set(persisted.map((m) => m.id))
516
+ // Persisted rows change only when the WS query delivers; mapping them (and
517
+ // building the id Set) in their own memos keeps that O(history) work out of
518
+ // the per-token recompute below AND keeps row object identities stable
519
+ // across streamed chunks, which is what lets memo(MessageTurn) skip
520
+ // re-rendering (and re-parsing markdown for) every finished turn.
521
+ const persisted: RenderMessage[] = useMemo(
522
+ () =>
523
+ persistedRecords.map((r) => ({
524
+ id: r.recordId,
525
+ role: r.data.role,
526
+ content: r.data.content ?? '',
527
+ parts: r.data.parts,
528
+ })),
529
+ [persistedRecords],
530
+ )
531
+ const persistedIds = useMemo(() => new Set(persisted.map((m) => m.id)), [persisted])
517
532
 
533
+ const messages: RenderMessage[] = useMemo(() => {
518
534
  const tail = inFlight.filter((m) => {
519
535
  // Only render in-flight items that belong to the chat we're currently
520
536
  // viewing. Prevents the previous chat's bubbles from leaking through
@@ -530,7 +546,7 @@ export function ChatPanel({
530
546
  return true
531
547
  })
532
548
  return [...persisted, ...tail]
533
- }, [persistedRecords, inFlight, chatId])
549
+ }, [persisted, persistedIds, inFlight, chatId])
534
550
 
535
551
  const [input, setInput] = useState('')
536
552
 
@@ -550,8 +566,12 @@ export function ChatPanel({
550
566
  useEffect(() => {
551
567
  const el = scrollRef.current
552
568
  if (!el || !stickToBottomRef.current) return
553
- el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
554
- }, [messages])
569
+ // While streaming, chunks arrive faster than a smooth scroll completes —
570
+ // each restart leaves the view lagging the newest text and pays an extra
571
+ // animation. Jump instantly during the stream; ease only for discrete
572
+ // changes (history load, completed turn).
573
+ el.scrollTo({ top: el.scrollHeight, behavior: isLoading ? 'auto' : 'smooth' })
574
+ }, [messages, isLoading])
555
575
 
556
576
  // Auto-grow textarea, capped at 200px.
557
577
  useEffect(() => {
@@ -578,6 +598,10 @@ export function ChatPanel({
578
598
  }
579
599
 
580
600
  function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
601
+ // IME composition: the Enter that COMMITS a CJK composition fires keydown
602
+ // with isComposing=true (keyCode 229 on older WebKit) — it must confirm
603
+ // the text, not send the half-composed message.
604
+ if (e.nativeEvent.isComposing || e.keyCode === 229) return
581
605
  if (e.key === 'Enter' && !e.shiftKey) {
582
606
  e.preventDefault()
583
607
  submit()
@@ -728,7 +752,12 @@ interface MessageTurnProps {
728
752
  isStreaming: boolean
729
753
  }
730
754
 
731
- function MessageTurn({ role, content, parts, isStreaming }: MessageTurnProps) {
755
+ // memo: every streamed chunk and composer keystroke re-renders ChatPanel and
756
+ // with it the whole turn list. Finished turns receive referentially stable
757
+ // props (see the `persisted` memo), so memo lets them skip the re-render —
758
+ // and with it a full ReactMarkdown re-parse per turn per chunk. Only the
759
+ // actively streaming row's props change, so only it re-renders.
760
+ const MessageTurn = memo(function MessageTurn({ role, content, parts, isStreaming }: MessageTurnProps) {
732
761
  const orderedParts: MessagePart[] = useMemo(() => {
733
762
  if (Array.isArray(parts) && parts.length > 0) {
734
763
  const cast = parts as MessagePart[]
@@ -804,9 +833,9 @@ function MessageTurn({ role, content, parts, isStreaming }: MessageTurnProps) {
804
833
  [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1"
805
834
  >
806
835
  <ReactMarkdown
807
- remarkPlugins={[remarkGfm, remarkBreaks]}
808
- rehypePlugins={[rehypeHighlight]}
809
- components={{ pre: CodeBlock, a: ExternalLink }}
836
+ remarkPlugins={REMARK_PLUGINS}
837
+ rehypePlugins={REHYPE_PLUGINS}
838
+ components={MARKDOWN_COMPONENTS}
810
839
  >
811
840
  {text}
812
841
  </ReactMarkdown>
@@ -822,7 +851,7 @@ function MessageTurn({ role, content, parts, isStreaming }: MessageTurnProps) {
822
851
  {isStreaming && <LiveIndicator />}
823
852
  </div>
824
853
  )
825
- }
854
+ })
826
855
 
827
856
  // ----- Empty state --------------------------------------------------------
828
857
 
@@ -943,6 +972,15 @@ const ExternalLink: Components['a'] = ({ children, href, node: _node, ...props }
943
972
  )
944
973
  }
945
974
 
975
+ // Module-level so ReactMarkdown's props stay referentially stable across
976
+ // renders — inline literals would defeat memo(MessageTurn) and re-run the
977
+ // remark/rehype parse for every finished turn on every streamed chunk.
978
+ // (Defined after CodeBlock/ExternalLink: they are const components, so an
979
+ // earlier literal would hit their temporal dead zone at module init.)
980
+ const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
981
+ const REHYPE_PLUGINS = [rehypeHighlight]
982
+ const MARKDOWN_COMPONENTS = { pre: CodeBlock, a: ExternalLink }
983
+
946
984
  function ToolRow({ inv }: { inv: ToolInvocation }) {
947
985
  const done = inv.state === 'result'
948
986
  const failed = done && inv.result?.success === false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepspace",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "engines": {
5
5
  "node": ">=22.15.0"
6
6
  },
package/dist/testing.d.ts DELETED
@@ -1,115 +0,0 @@
1
- import * as _playwright_test from '@playwright/test';
2
- import { BrowserContext, Page, Browser } from '@playwright/test';
3
- export { expect } from '@playwright/test';
4
-
5
- interface MultiplayerUser {
6
- context: BrowserContext;
7
- page: Page;
8
- email: string;
9
- name: string;
10
- /** Test account user ID, if known from the accounts registry. */
11
- userId?: string;
12
- }
13
- /**
14
- * Fixture function signature. Pass either a count (`users(2)`) or an
15
- * array of account names (`users(['Alice', 'Bob'])`). Returns one entry
16
- * per requested user.
17
- */
18
- type UsersFixture = (selector: number | string[], options?: {
19
- label?: string;
20
- }) => Promise<MultiplayerUser[]>;
21
- interface MultiplayerFixtures {
22
- users: UsersFixture;
23
- }
24
- declare const test: _playwright_test.TestType<_playwright_test.PlaywrightTestArgs & _playwright_test.PlaywrightTestOptions & MultiplayerFixtures, _playwright_test.PlaywrightWorkerArgs & _playwright_test.PlaywrightWorkerOptions>;
25
-
26
- /**
27
- * Account-credential plumbing for the testing module.
28
- *
29
- * Reads test accounts from `~/.deepspace/test-accounts.json` (the same
30
- * file `deepspace test-accounts create` writes). Each account has at
31
- * minimum `{email, password}`; `name` and `label` are optional.
32
- *
33
- * Used by the multi-user Playwright fixture in `./fixtures.ts` and is
34
- * also exported standalone in case suites want to do their own
35
- * orchestration.
36
- */
37
- interface TestAccount {
38
- email: string;
39
- password: string;
40
- name?: string;
41
- label?: string | null;
42
- id?: string;
43
- userId?: string;
44
- createdAt?: number;
45
- }
46
- /**
47
- * Load all test accounts the developer has created. Returns an empty
48
- * array if the file doesn't exist yet.
49
- */
50
- declare function loadAllTestAccounts(): TestAccount[];
51
- /**
52
- * Pick `count` test accounts from the local registry, optionally
53
- * filtered by `label`. Order-stable: sorted by `createdAt` ascending so
54
- * the first test account you created is always the first one returned.
55
- *
56
- * Throws with a helpful message if not enough accounts exist.
57
- */
58
- declare function pickTestAccounts(count: number, options?: {
59
- label?: string;
60
- }): TestAccount[];
61
- /**
62
- * Find a single account by name. Used by `users(['Alice', 'Bob'])`.
63
- * Throws if not found.
64
- */
65
- declare function findTestAccountByName(name: string): TestAccount;
66
-
67
- /**
68
- * Per-account `storageState` cache for Playwright.
69
- *
70
- * Better Auth applies a per-IP rate limit on `/api/auth/sign-in/email`
71
- * (~5 sign-ins / 60s / endpoint). A multiplayer suite that spins up
72
- * 5+ users per spec would hit the limit and start failing in non-obvious
73
- * ways. This module signs each account in *once*, persists the
74
- * resulting browser cookies + storage to disk, and reuses the file on
75
- * subsequent runs.
76
- *
77
- * Cache layout:
78
- * ~/.deepspace/playwright-states/<sha256(email)>.json
79
- *
80
- * Validity: a cached file is reused if Playwright successfully loads it
81
- * AND the resulting context produces a non-anonymous session on the
82
- * target app. If validation fails, we fall back to a fresh sign-in and
83
- * overwrite the cache.
84
- */
85
-
86
- interface EnsureStorageStateOptions {
87
- /** Max age of a cached state file before we re-sign-in. Default 7 days. */
88
- maxAgeMs?: number;
89
- /** Force a fresh sign-in even if the cache is fresh. */
90
- force?: boolean;
91
- }
92
- /**
93
- * Ensure a Playwright `storageState` file exists for `account` and
94
- * return its path. Signs in once if the cache is missing, stale, or
95
- * `force: true`.
96
- */
97
- declare function ensureStorageState(browser: Browser, account: {
98
- email: string;
99
- password: string;
100
- }, baseURL: string, options?: EnsureStorageStateOptions): Promise<string>;
101
- /**
102
- * Convenience: open a fresh `BrowserContext` for `account` using the
103
- * cached storage state (signing in if needed). The caller is responsible
104
- * for closing the context.
105
- */
106
- declare function newSignedInContext(browser: Browser, account: {
107
- email: string;
108
- password: string;
109
- }, baseURL: string, options?: EnsureStorageStateOptions): Promise<BrowserContext>;
110
- /** Read a cached storageState file (mostly for debugging). */
111
- declare function readCachedState(email: string): unknown | null;
112
- /** Where the cache for `email` lives. Useful for diagnostics. */
113
- declare function getStatePathForEmail(email: string): string;
114
-
115
- export { type EnsureStorageStateOptions, type MultiplayerUser, type TestAccount, type UsersFixture, ensureStorageState, findTestAccountByName, getStatePathForEmail, loadAllTestAccounts, newSignedInContext, pickTestAccounts, readCachedState, test };