@exegia/corpora-ui 0.20.0 → 0.21.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 (123) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/auth-state-atom.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
  3. package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
  4. package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
  5. package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
  6. package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
  7. package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
  8. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
  9. package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
  10. package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
  11. package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
  12. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +109 -29
  13. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
  14. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +6 -1
  15. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +1 -1
  16. package/dist-lib/components/blocks/profile/index.d.ts +7 -0
  17. package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
  18. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
  19. package/dist-lib/components/blocks/profile/type.d.ts +80 -0
  20. package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
  21. package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
  22. package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
  23. package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
  24. package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
  25. package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
  26. package/dist-lib/components/blocks/shell/index.d.ts +4 -0
  27. package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
  28. package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
  29. package/dist-lib/components/blocks/shell/type.d.ts +115 -3
  30. package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
  31. package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
  32. package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
  33. package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
  34. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  35. package/dist-lib/components/composed/tree/constants.d.ts +4 -4
  36. package/dist-lib/components/composed/tree/index.d.ts +3 -1
  37. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  38. package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
  39. package/dist-lib/components/composed/tree/type.d.ts +116 -18
  40. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
  41. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  42. package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
  43. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  44. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  45. package/dist-lib/components/user-avatar/component.d.ts +1 -8
  46. package/dist-lib/components/user-avatar/index.d.ts +5 -0
  47. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  48. package/dist-lib/components/user-avatar/type.d.ts +45 -0
  49. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  50. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
  51. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
  52. package/dist-lib/index.d.ts +5 -2
  53. package/dist-lib/index.js +3004 -1796
  54. package/dist-lib/index.js.map +1 -1
  55. package/dist-lib/state/exegia-provider.d.ts +51 -0
  56. package/dist-lib/state/index.d.ts +4 -0
  57. package/dist-lib/state/store.d.ts +17 -0
  58. package/package.json +15 -12
  59. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  60. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  61. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  62. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  63. package/src/components/blocks/auth/auth-state.ts +52 -0
  64. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  65. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  66. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  67. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
  68. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  69. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  70. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  71. package/src/components/blocks/nav/sidebar/type.ts +125 -30
  72. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  73. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
  74. package/src/components/blocks/nav/sidebar/utils.ts +1 -1
  75. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  76. package/src/components/blocks/profile/index.ts +28 -0
  77. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  78. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  79. package/src/components/blocks/profile/type.ts +95 -0
  80. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  81. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  82. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  83. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  84. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  85. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  86. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  87. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  88. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  89. package/src/components/blocks/shell/index.ts +18 -0
  90. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  91. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  92. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  93. package/src/components/blocks/shell/type.ts +130 -3
  94. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  95. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  96. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  97. package/src/components/blocks/shell/utils.ts +44 -4
  98. package/src/components/composed/tree/CLAUDE.md +132 -0
  99. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  100. package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
  101. package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
  102. package/src/components/composed/tree/constants.ts +4 -4
  103. package/src/components/composed/tree/index.ts +38 -0
  104. package/src/components/composed/tree/tree-atom.ts +590 -0
  105. package/src/components/composed/tree/tree-node.tsx +117 -78
  106. package/src/components/composed/tree/tree.tsx +24 -16
  107. package/src/components/composed/tree/type.ts +125 -15
  108. package/src/components/composed/tree/use-tree-dnd.ts +82 -51
  109. package/src/components/composed/tree/use-tree-state.ts +105 -0
  110. package/src/components/composed/tree/use-tree.ts +185 -184
  111. package/src/components/composed/user-avatar.tsx +11 -99
  112. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
  113. package/src/components/user-avatar/component.tsx +121 -22
  114. package/src/components/user-avatar/index.ts +19 -0
  115. package/src/components/user-avatar/presence-badge.tsx +56 -0
  116. package/src/components/user-avatar/type.ts +50 -0
  117. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  118. package/src/components/user-avatar/use-user-avatar.ts +144 -0
  119. package/src/components/user-avatar/user-avatar-atom.ts +198 -0
  120. package/src/index.ts +34 -2
  121. package/src/state/exegia-provider.tsx +79 -0
  122. package/src/state/index.ts +4 -0
  123. package/src/state/store.ts +19 -0
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Session state for the auth blocks — singleton atoms, deliberately NOT a
3
+ * family.
4
+ *
5
+ * A session is one-per-store: one signed-in user per app (per
6
+ * `ExegiaProvider` store; SSR isolation comes from passing a fresh store).
7
+ * Instance keying is for multi-instance components; a family here would
8
+ * imply several concurrent users and be a lie.
9
+ *
10
+ * Import direction: this module is the LEAF — it imports nothing from
11
+ * `auth-flow-atom.ts`. The flow module imports `signInAtom` from here (for
12
+ * `completeAuthFlowAtom`) and defines the composite `endAuthSessionAtom`
13
+ * that pairs `signOutAtom` with a flow reset. `signOutAtom` below clears the
14
+ * session only.
15
+ */
16
+ import { atom } from "jotai"
17
+
18
+ import type {
19
+ AuthSessionState,
20
+ AuthSessionStatus,
21
+ AuthUser,
22
+ } from "./auth-state-type"
23
+
24
+ // ── primitives ───────────────────────────────────────────────────────────
25
+
26
+ /** `"unknown"` until the app restores or rejects a session at boot. */
27
+ export const authSessionStatusAtom = atom<AuthSessionStatus>("unknown")
28
+ authSessionStatusAtom.debugLabel = "auth-session/status"
29
+
30
+ export const authUserAtom = atom<AuthUser | null>(null)
31
+ authUserAtom.debugLabel = "auth-session/user"
32
+
33
+ // ── derived ──────────────────────────────────────────────────────────────
34
+
35
+ export const isAuthenticatedAtom = atom(
36
+ (get) => get(authSessionStatusAtom) === "authenticated"
37
+ )
38
+ isAuthenticatedAtom.debugLabel = "auth-session/isAuthenticated"
39
+
40
+ export const authSessionStateAtom = atom<AuthSessionState>((get) => ({
41
+ status: get(authSessionStatusAtom),
42
+ user: get(authUserAtom),
43
+ }))
44
+ authSessionStateAtom.debugLabel = "auth-session/state"
45
+
46
+ // ── actions ──────────────────────────────────────────────────────────────
47
+
48
+ export const signInAtom = atom(null, (_get, set, user: AuthUser) => {
49
+ set(authUserAtom, user)
50
+ set(authSessionStatusAtom, "authenticated")
51
+ })
52
+ signInAtom.debugLabel = "auth-session/signIn"
53
+
54
+ export const updateAuthUserAtom = atom(
55
+ null,
56
+ (get, set, patch: Partial<AuthUser>) => {
57
+ const user = get(authUserAtom)
58
+ // No-op when signed out: there is no user to merge into, and inventing
59
+ // one from a partial patch would fabricate an identity.
60
+ if (user === null) return
61
+ set(authUserAtom, { ...user, ...patch })
62
+ }
63
+ )
64
+ updateAuthUserAtom.debugLabel = "auth-session/updateUser"
65
+
66
+ /** What an app calls at boot when session restore fails. */
67
+ export const markUnauthenticatedAtom = atom(null, (_get, set) => {
68
+ set(authSessionStatusAtom, "unauthenticated")
69
+ set(authUserAtom, null)
70
+ })
71
+ markUnauthenticatedAtom.debugLabel = "auth-session/markUnauthenticated"
72
+
73
+ /**
74
+ * Clears the session only. Most consumers want `endAuthSessionAtom` from
75
+ * `auth-flow-atom.ts` instead, which also returns the default flow to the
76
+ * login step — the hooks expose that one as `signOut`.
77
+ */
78
+ export const signOutAtom = atom(null, (_get, set) => {
79
+ set(authUserAtom, null)
80
+ set(authSessionStatusAtom, "unauthenticated")
81
+ })
82
+ signOutAtom.debugLabel = "auth-session/signOut"
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Types for the auth coordination state (`auth-flow-atom.ts` /
3
+ * `auth-session-atom.ts`).
4
+ *
5
+ * This file stays dependency-free of the atom modules: types only, no atom
6
+ * imports. The auth blocks themselves are unaware of this layer — they keep
7
+ * field values in local `useState` — these types describe the coordination
8
+ * state an app wires *around* the blocks.
9
+ */
10
+ import type { AuthStatus } from "./auth-shell"
11
+
12
+ export type AuthFlowId = string
13
+
14
+ /** Which auth block the app is showing. */
15
+ export type AuthFlowStep =
16
+ | "login"
17
+ | "signup"
18
+ | "verify-code"
19
+ | "forgot-password"
20
+ | "update-password"
21
+ | "onboarding"
22
+ | "success"
23
+
24
+ export type AuthFlowChannel = "email" | "sms"
25
+
26
+ export interface AuthFlowState {
27
+ step: AuthFlowStep
28
+ /** The identifier in flight — an email or phone number. Cleared by reset
29
+ * and by signOut. Never store passwords or codes here. */
30
+ identifier: string | null
31
+ /** `identifier` masked for display — "y•••@example.com", "•••1234". */
32
+ maskedIdentifier: string | null
33
+ channel: AuthFlowChannel
34
+ status: AuthStatus
35
+ error: string | null
36
+ }
37
+
38
+ /** Options for `beginAuthVerificationAtom` / `beginVerification`. */
39
+ export interface BeginAuthVerificationOptions {
40
+ identifier: string
41
+ /** @default "email" */
42
+ channel?: AuthFlowChannel
43
+ /** @default "verify-code" */
44
+ step?: AuthFlowStep
45
+ }
46
+
47
+ /** What `useAuthFlowActions(flowId)` returns. */
48
+ export interface AuthFlowActions {
49
+ /** Show a step; resets status to `"idle"` and clears the error. Keeps the
50
+ * identifier. */
51
+ goToStep: (step: AuthFlowStep) => void
52
+ /** Record the identifier in flight and move to the verification step. */
53
+ beginVerification: (options: BeginAuthVerificationOptions) => void
54
+ /** Mark the flow `"loading"` while an async attempt is in flight. */
55
+ beginAttempt: () => void
56
+ /** Mark the flow `"error"` with a message. */
57
+ fail: (message: string) => void
58
+ /** Mark the flow `"success"`; when a user is given, also sign the session
59
+ * in. */
60
+ complete: (user?: AuthUser) => void
61
+ /** Back to the initial values (login step, no identifier). */
62
+ reset: () => void
63
+ }
64
+
65
+ /** The signed-in identity, kept for the whole session. Structurally
66
+ * assignable to `ProfileCardUser`, so it drops into `ProfileCardBlock`. */
67
+ export interface AuthUser {
68
+ id: string
69
+ name: string
70
+ /** Secondary line — a handle, an email, a role. */
71
+ username?: string
72
+ email?: string
73
+ /** Avatar image URL. */
74
+ avatar?: string
75
+ initials?: string
76
+ /** App-specific extras the library never interprets. */
77
+ metadata?: Record<string, unknown>
78
+ }
79
+
80
+ /** `unknown` until the app restores or rejects a session at boot. */
81
+ export type AuthSessionStatus = "unknown" | "unauthenticated" | "authenticated"
82
+
83
+ export interface AuthSessionState {
84
+ status: AuthSessionStatus
85
+ user: AuthUser | null
86
+ }
87
+
88
+ /** What `useAuthSessionActions()` returns. */
89
+ export interface AuthSessionActions {
90
+ signIn: (user: AuthUser) => void
91
+ /** Shallow-merge a patch into the signed-in user; no-op when signed out. */
92
+ updateUser: (patch: Partial<AuthUser>) => void
93
+ /** What an app calls at boot when session restore fails. */
94
+ markUnauthenticated: () => void
95
+ /** Sign out AND return the default auth flow to the login step. */
96
+ signOut: () => void
97
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Public barrel for the auth coordination state. Explicit named exports
3
+ * only — never `export *` from the atom modules, so internals can move
4
+ * without becoming a breaking-change surface.
5
+ */
6
+ export {
7
+ DEFAULT_AUTH_FLOW_ID,
8
+ authFlowChannelAtom,
9
+ authFlowErrorAtom,
10
+ authFlowIdentifierAtom,
11
+ authFlowMaskedIdentifierAtom,
12
+ authFlowStateAtom,
13
+ authFlowStatusAtom,
14
+ authFlowStepAtom,
15
+ beginAuthAttemptAtom,
16
+ beginAuthVerificationAtom,
17
+ completeAuthFlowAtom,
18
+ endAuthSessionAtom,
19
+ failAuthFlowAtom,
20
+ goToAuthStepAtom,
21
+ maskAuthIdentifier,
22
+ removeAuthFlowInstance,
23
+ resetAuthFlowAtom,
24
+ } from "./auth-flow-atom"
25
+ export {
26
+ authSessionStateAtom,
27
+ authSessionStatusAtom,
28
+ authUserAtom,
29
+ isAuthenticatedAtom,
30
+ markUnauthenticatedAtom,
31
+ signInAtom,
32
+ signOutAtom,
33
+ updateAuthUserAtom,
34
+ } from "./auth-session-atom"
35
+ export {
36
+ useAuthFlow,
37
+ useAuthFlowActions,
38
+ useAuthSession,
39
+ useAuthSessionActions,
40
+ } from "./use-auth-state"
41
+ export type {
42
+ AuthFlowActions,
43
+ AuthFlowChannel,
44
+ AuthFlowId,
45
+ AuthFlowState,
46
+ AuthFlowStep,
47
+ AuthSessionActions,
48
+ AuthSessionState,
49
+ AuthSessionStatus,
50
+ AuthUser,
51
+ BeginAuthVerificationOptions,
52
+ } from "./auth-state-type"
@@ -0,0 +1,127 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
5
+
6
+ import {
7
+ DEFAULT_AUTH_FLOW_ID,
8
+ authFlowStateAtom,
9
+ beginAuthAttemptAtom,
10
+ beginAuthVerificationAtom,
11
+ completeAuthFlowAtom,
12
+ endAuthSessionAtom,
13
+ failAuthFlowAtom,
14
+ goToAuthStepAtom,
15
+ resetAuthFlowAtom,
16
+ } from "./auth-flow-atom"
17
+ import {
18
+ authSessionStateAtom,
19
+ isAuthenticatedAtom,
20
+ markUnauthenticatedAtom,
21
+ signInAtom,
22
+ updateAuthUserAtom,
23
+ } from "./auth-session-atom"
24
+ import type {
25
+ AuthFlowActions,
26
+ AuthFlowId,
27
+ AuthFlowState,
28
+ AuthSessionActions,
29
+ AuthSessionState,
30
+ } from "./auth-state-type"
31
+
32
+ /**
33
+ * Read the auth flow registered under `flowId` from anywhere below
34
+ * `ExegiaProvider`. Pairs with `useAuthFlowActions` to wire the auth blocks
35
+ * into one flow — the blocks keep passwords and codes in local state, the
36
+ * flow tracks which block shows and what identifier is in flight:
37
+ *
38
+ * ```tsx
39
+ * const flow = useAuthFlow()
40
+ * const { beginVerification, complete, fail } = useAuthFlowActions()
41
+ *
42
+ * {flow.step === "login" && (
43
+ * <LoginBlock
44
+ * onSubmit={async ({ email }) => {
45
+ * await api.requestCode(email)
46
+ * beginVerification({ identifier: email })
47
+ * }}
48
+ * />
49
+ * )}
50
+ * {flow.step === "verify-code" && (
51
+ * <CodeAuthBlock
52
+ * destination={flow.maskedIdentifier ?? undefined}
53
+ * onVerify={async (code) => complete(await api.verify(code))}
54
+ * />
55
+ * )}
56
+ * ```
57
+ *
58
+ * This returns the whole state object, so the caller re-renders on any
59
+ * change. A component that reads one field should subscribe to that field's
60
+ * atom instead: `useAtomValue(authFlowStepAtom(flowId))`.
61
+ */
62
+ export function useAuthFlow(
63
+ flowId: AuthFlowId = DEFAULT_AUTH_FLOW_ID
64
+ ): AuthFlowState {
65
+ return useAtomValue(authFlowStateAtom(flowId))
66
+ }
67
+
68
+ /**
69
+ * Drive the auth flow registered under `flowId` from anywhere. Writes only —
70
+ * the caller never re-renders when the flow changes. See `useAuthFlow` for
71
+ * the intended wiring; a block's submit handler typically runs
72
+ * `beginAttempt()` → the API call → `complete(user)` or `fail(message)`.
73
+ */
74
+ export function useAuthFlowActions(
75
+ flowId: AuthFlowId = DEFAULT_AUTH_FLOW_ID
76
+ ): AuthFlowActions {
77
+ const goToStep = useSetAtom(goToAuthStepAtom(flowId))
78
+ const beginVerification = useSetAtom(beginAuthVerificationAtom(flowId))
79
+ const beginAttempt = useSetAtom(beginAuthAttemptAtom(flowId))
80
+ const fail = useSetAtom(failAuthFlowAtom(flowId))
81
+ const complete = useSetAtom(completeAuthFlowAtom(flowId))
82
+ const reset = useSetAtom(resetAuthFlowAtom(flowId))
83
+
84
+ return React.useMemo(
85
+ () => ({
86
+ goToStep,
87
+ beginVerification,
88
+ beginAttempt,
89
+ fail,
90
+ complete,
91
+ reset,
92
+ }),
93
+ [goToStep, beginVerification, beginAttempt, fail, complete, reset]
94
+ )
95
+ }
96
+
97
+ /**
98
+ * Read the session: `status` (`"unknown"` until the app restores or rejects
99
+ * a session at boot), the signed-in `user`, and derived `isAuthenticated`.
100
+ */
101
+ export function useAuthSession(): AuthSessionState & {
102
+ isAuthenticated: boolean
103
+ } {
104
+ const state = useAtomValue(authSessionStateAtom)
105
+ const isAuthenticated = useAtomValue(isAuthenticatedAtom)
106
+ return React.useMemo(
107
+ () => ({ ...state, isAuthenticated }),
108
+ [state, isAuthenticated]
109
+ )
110
+ }
111
+
112
+ /**
113
+ * Drive the session. Writes only. `signOut` is the composite
114
+ * `endAuthSessionAtom`: it clears the session AND returns the default flow
115
+ * to the login step — the one to wire to a logout button.
116
+ */
117
+ export function useAuthSessionActions(): AuthSessionActions {
118
+ const signIn = useSetAtom(signInAtom)
119
+ const updateUser = useSetAtom(updateAuthUserAtom)
120
+ const markUnauthenticated = useSetAtom(markUnauthenticatedAtom)
121
+ const signOut = useSetAtom(endAuthSessionAtom)
122
+
123
+ return React.useMemo(
124
+ () => ({ signIn, updateUser, markUnauthenticated, signOut }),
125
+ [signIn, updateUser, markUnauthenticated, signOut]
126
+ )
127
+ }
@@ -0,0 +1,325 @@
1
+ import { describe, expect, mock, test } from "bun:test"
2
+ import { render, screen } from "@testing-library/react"
3
+ import userEvent from "@testing-library/user-event"
4
+ import { createStore } from "jotai"
5
+
6
+ import { ExegiaProvider } from "@/state"
7
+ import { AISidebar } from "../ai-sidebar"
8
+ import {
9
+ DEFAULT_AI_SIDEBAR_CONFIG,
10
+ aiSidebarAnnouncementAtom,
11
+ aiSidebarExpandedIdsAtom,
12
+ aiSidebarItemsAtom,
13
+ aiSidebarMovePendingAtom,
14
+ expandAISidebarRowAtom,
15
+ mountAISidebarAtom,
16
+ moveAISidebarRowAtom,
17
+ removeAISidebarInstance,
18
+ renameAISidebarRowAtom,
19
+ resetAISidebarAtom,
20
+ setAISidebarHandlersAtom,
21
+ } from "../ai-sidebar-atom"
22
+ import type { AISidebarConfig, AISidebarSeed, SidebarResource } from "../type"
23
+ import { useAISidebarActions, useAISidebarState } from "../use-ai-sidebar-state"
24
+
25
+ type Store = ReturnType<typeof createStore>
26
+
27
+ const RESOURCES: SidebarResource[] = [
28
+ {
29
+ id: "corpora",
30
+ label: "Corpora",
31
+ kind: "project",
32
+ children: [
33
+ { id: "manuscripts", label: "Manuscripts", kind: "folder" },
34
+ { id: "readme", label: "Field notes", kind: "file" },
35
+ ],
36
+ },
37
+ { id: "reading-list", label: "Reading list", kind: "bookmark" },
38
+ { id: "archive", label: "Archive", kind: "folder", disabled: true },
39
+ ]
40
+
41
+ function mount(
42
+ store: Store,
43
+ id: string,
44
+ config: Partial<AISidebarConfig> = {},
45
+ seed: Partial<AISidebarSeed> = {}
46
+ ) {
47
+ store.set(
48
+ mountAISidebarAtom(id),
49
+ { ...DEFAULT_AI_SIDEBAR_CONFIG, ...config },
50
+ {
51
+ items: RESOURCES,
52
+ activeId: null,
53
+ focusedId: null,
54
+ expandedIds: [],
55
+ ...seed,
56
+ }
57
+ )
58
+ }
59
+
60
+ const ids = (items: SidebarResource[]) => items.map((item) => item.id)
61
+
62
+ describe("ai-sidebar atoms · instance isolation", () => {
63
+ test("two ids never share state", () => {
64
+ const store = createStore()
65
+ mount(store, "a")
66
+ mount(store, "b")
67
+
68
+ store.set(expandAISidebarRowAtom("a"), "corpora")
69
+ expect(store.get(aiSidebarExpandedIdsAtom("a")).has("corpora")).toBe(true)
70
+ expect(store.get(aiSidebarExpandedIdsAtom("b")).has("corpora")).toBe(false)
71
+ })
72
+
73
+ test("two stores never share state under the same id", () => {
74
+ const one = createStore()
75
+ const two = createStore()
76
+ mount(one, "shared")
77
+ mount(two, "shared")
78
+
79
+ one.set(expandAISidebarRowAtom("shared"), "corpora")
80
+ expect(one.get(aiSidebarExpandedIdsAtom("shared")).has("corpora")).toBe(
81
+ true
82
+ )
83
+ expect(two.get(aiSidebarExpandedIdsAtom("shared")).has("corpora")).toBe(
84
+ false
85
+ )
86
+ })
87
+
88
+ test("removeAISidebarInstance drops the slice back to its defaults", () => {
89
+ const store = createStore()
90
+ mount(store, "gone")
91
+ store.set(expandAISidebarRowAtom("gone"), "corpora")
92
+
93
+ removeAISidebarInstance("gone")
94
+ expect(store.get(aiSidebarExpandedIdsAtom("gone")).size).toBe(0)
95
+ expect(store.get(aiSidebarItemsAtom("gone"))).toEqual([])
96
+ })
97
+ })
98
+
99
+ describe("ai-sidebar atoms · write gates", () => {
100
+ test("hook-owned data renames itself", () => {
101
+ const store = createStore()
102
+ mount(store, "owned")
103
+
104
+ store.set(renameAISidebarRowAtom("owned"), "reading-list", " To read ")
105
+ expect(store.get(aiSidebarItemsAtom("owned"))[1]?.label).toBe("To read")
106
+ })
107
+
108
+ test("a controlled items prop is never overwritten, only reported", () => {
109
+ const store = createStore()
110
+ const onItemsChange = mock((items: SidebarResource[]) => void items)
111
+ mount(store, "controlled", { controlsItems: true })
112
+ store.set(setAISidebarHandlersAtom("controlled"), { onItemsChange })
113
+
114
+ store.set(renameAISidebarRowAtom("controlled"), "reading-list", "To read")
115
+ expect(store.get(aiSidebarItemsAtom("controlled"))[1]?.label).toBe(
116
+ "Reading list"
117
+ )
118
+ expect(onItemsChange).toHaveBeenCalledTimes(1)
119
+ expect(onItemsChange.mock.calls[0]![0][1]?.label).toBe("To read")
120
+
121
+ // A move is gated the same way: the atom keeps the prop's data, the
122
+ // consumer gets the next tree to apply themselves.
123
+ void store.set(moveAISidebarRowAtom("controlled"), {
124
+ itemId: "reading-list",
125
+ targetId: "corpora",
126
+ position: "before",
127
+ })
128
+ expect(ids(store.get(aiSidebarItemsAtom("controlled")))).toEqual([
129
+ "corpora",
130
+ "reading-list",
131
+ "archive",
132
+ ])
133
+ expect(onItemsChange).toHaveBeenCalledTimes(2)
134
+ expect(ids(onItemsChange.mock.calls[1]![0])).toEqual([
135
+ "reading-list",
136
+ "corpora",
137
+ "archive",
138
+ ])
139
+ })
140
+
141
+ test("reset replays the mount seed", () => {
142
+ const store = createStore()
143
+ mount(store, "replay", {}, { expandedIds: ["corpora"] })
144
+ store.set(expandAISidebarRowAtom("replay"), "manuscripts")
145
+ store.set(renameAISidebarRowAtom("replay"), "reading-list", "To read")
146
+
147
+ store.set(resetAISidebarAtom("replay"))
148
+ expect([...store.get(aiSidebarExpandedIdsAtom("replay"))]).toEqual([
149
+ "corpora",
150
+ ])
151
+ expect(store.get(aiSidebarItemsAtom("replay"))[1]?.label).toBe(
152
+ "Reading list"
153
+ )
154
+ expect(store.get(aiSidebarAnnouncementAtom("replay"))).toBe("")
155
+ })
156
+ })
157
+
158
+ describe("ai-sidebar atoms · optimistic move", () => {
159
+ test("a rejected onMove rolls the data back and announces it", async () => {
160
+ const store = createStore()
161
+ const onMoveError = mock(() => {})
162
+ mount(store, "rollback")
163
+ store.set(setAISidebarHandlersAtom("rollback"), {
164
+ onMove: () => Promise.reject(new Error("409")),
165
+ onMoveError,
166
+ })
167
+
168
+ const pending = store.set(moveAISidebarRowAtom("rollback"), {
169
+ itemId: "reading-list",
170
+ targetId: "corpora",
171
+ position: "before",
172
+ })
173
+ // The move applies optimistically and announces before onMove settles…
174
+ expect(ids(store.get(aiSidebarItemsAtom("rollback")))).toEqual([
175
+ "reading-list",
176
+ "corpora",
177
+ "archive",
178
+ ])
179
+ expect(store.get(aiSidebarAnnouncementAtom("rollback"))).toContain(
180
+ "Moved Reading list before Corpora"
181
+ )
182
+
183
+ await pending
184
+ // …and the rejection restores the previous order.
185
+ expect(ids(store.get(aiSidebarItemsAtom("rollback")))).toEqual([
186
+ "corpora",
187
+ "reading-list",
188
+ "archive",
189
+ ])
190
+ expect(store.get(aiSidebarAnnouncementAtom("rollback"))).toContain(
191
+ "Move failed"
192
+ )
193
+ expect(onMoveError).toHaveBeenCalled()
194
+ expect(store.get(aiSidebarMovePendingAtom("rollback"))).toBe(false)
195
+ })
196
+
197
+ test("a second move is refused while one is in flight", async () => {
198
+ const store = createStore()
199
+ let release!: () => void
200
+ mount(store, "pending")
201
+ store.set(setAISidebarHandlersAtom("pending"), {
202
+ onMove: () =>
203
+ new Promise<void>((resolve) => {
204
+ release = resolve
205
+ }),
206
+ })
207
+
208
+ const first = store.set(moveAISidebarRowAtom("pending"), {
209
+ itemId: "reading-list",
210
+ targetId: "corpora",
211
+ position: "before",
212
+ })
213
+ const second = store.set(moveAISidebarRowAtom("pending"), {
214
+ itemId: "archive",
215
+ targetId: "corpora",
216
+ position: "before",
217
+ })
218
+ // The second move never touched the data, only the live region.
219
+ expect(store.get(aiSidebarAnnouncementAtom("pending"))).toBe(
220
+ "Wait for the current move to finish."
221
+ )
222
+ expect(ids(store.get(aiSidebarItemsAtom("pending")))).toEqual([
223
+ "reading-list",
224
+ "corpora",
225
+ "archive",
226
+ ])
227
+
228
+ release()
229
+ await Promise.all([first, second])
230
+ expect(store.get(aiSidebarMovePendingAtom("pending"))).toBe(false)
231
+ })
232
+ })
233
+
234
+ describe("ExegiaProvider · driving a sidebar by id", () => {
235
+ /** A component with no relationship to the sidebar beyond its id. */
236
+ function Remote() {
237
+ const actions = useAISidebarActions("app-resources")
238
+ const { expandedIds } = useAISidebarState("app-resources")
239
+ return (
240
+ <button onClick={() => actions.expand("corpora")} type="button">
241
+ {`expand (${expandedIds.size})`}
242
+ </button>
243
+ )
244
+ }
245
+
246
+ test("a sibling component expands the sidebar through one provider", async () => {
247
+ const user = userEvent.setup()
248
+ render(
249
+ <ExegiaProvider store={createStore()}>
250
+ <AISidebar defaultItems={RESOURCES} sidebarId="app-resources" />
251
+ <Remote />
252
+ </ExegiaProvider>
253
+ )
254
+
255
+ // corpora starts collapsed, so its children start unmounted.
256
+ expect(screen.queryByRole("treeitem", { name: /Field notes/ })).toBeNull()
257
+
258
+ await user.click(screen.getByRole("button", { name: /^expand/ }))
259
+ expect(
260
+ await screen.findByRole("treeitem", { name: /Field notes/ })
261
+ ).toBeDefined()
262
+ // The remote reader saw the change too.
263
+ expect(screen.getByRole("button", { name: "expand (1)" })).toBeDefined()
264
+ })
265
+ })
266
+
267
+ describe("AISidebar · render granularity", () => {
268
+ test("expanding and selecting one row leave a sibling row untouched", async () => {
269
+ const user = userEvent.setup()
270
+ // `renderIcon` runs inside a row's render, so counting calls per id is a
271
+ // proxy for counting that row's renders.
272
+ const renders = new Map<string, number>()
273
+
274
+ function Driver() {
275
+ const actions = useAISidebarActions("granular")
276
+ return (
277
+ <>
278
+ <button onClick={() => actions.expand("corpora")} type="button">
279
+ open
280
+ </button>
281
+ <button onClick={() => actions.select("reading-list")} type="button">
282
+ pick
283
+ </button>
284
+ </>
285
+ )
286
+ }
287
+
288
+ render(
289
+ <ExegiaProvider store={createStore()}>
290
+ <AISidebar
291
+ defaultItems={RESOURCES}
292
+ renderIcon={(item) => {
293
+ renders.set(item.id, (renders.get(item.id) ?? 0) + 1)
294
+ return null
295
+ }}
296
+ sidebarId="granular"
297
+ />
298
+ <Driver />
299
+ </ExegiaProvider>
300
+ )
301
+
302
+ const beforeCorpora = renders.get("corpora") ?? 0
303
+ const beforeReadingList = renders.get("reading-list") ?? 0
304
+ const beforeArchive = renders.get("archive") ?? 0
305
+ expect(beforeCorpora).toBeGreaterThan(0)
306
+ expect(beforeReadingList).toBeGreaterThan(0)
307
+
308
+ await user.click(screen.getByRole("button", { name: "open" }))
309
+ await screen.findByRole("treeitem", { name: /Field notes/ })
310
+
311
+ // corpora's branch opened, so its row re-rendered…
312
+ expect(renders.get("corpora")).toBeGreaterThan(beforeCorpora)
313
+ // …and its siblings, whose atoms did not change, did not.
314
+ expect(renders.get("reading-list")).toBe(beforeReadingList)
315
+ expect(renders.get("archive")).toBe(beforeArchive)
316
+
317
+ const expandedCorpora = renders.get("corpora") ?? 0
318
+ await user.click(screen.getByRole("button", { name: "pick" }))
319
+
320
+ // Selection flips reading-list's own atom and nobody else's.
321
+ expect(renders.get("reading-list")).toBeGreaterThan(beforeReadingList)
322
+ expect(renders.get("corpora")).toBe(expandedCorpora)
323
+ expect(renders.get("archive")).toBe(beforeArchive)
324
+ })
325
+ })