@stigmer/react 0.5.0 → 0.5.1

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 (94) hide show
  1. package/composer/ContextChip.d.ts +7 -2
  2. package/composer/ContextChip.d.ts.map +1 -1
  3. package/composer/ContextChip.js +2 -1
  4. package/composer/ContextChip.js.map +1 -1
  5. package/composer/SessionComposer.d.ts +11 -0
  6. package/composer/SessionComposer.d.ts.map +1 -1
  7. package/composer/SessionComposer.js +33 -4
  8. package/composer/SessionComposer.js.map +1 -1
  9. package/environment/usePersonalEnvironment.d.ts.map +1 -1
  10. package/environment/usePersonalEnvironment.js +1 -0
  11. package/environment/usePersonalEnvironment.js.map +1 -1
  12. package/index.d.ts +2 -2
  13. package/index.d.ts.map +1 -1
  14. package/index.js +1 -1
  15. package/index.js.map +1 -1
  16. package/inline-edit/InlineEditKeyValue.d.ts +5 -1
  17. package/inline-edit/InlineEditKeyValue.d.ts.map +1 -1
  18. package/inline-edit/InlineEditKeyValue.js +3 -3
  19. package/inline-edit/InlineEditKeyValue.js.map +1 -1
  20. package/internal/useFetch.js +2 -2
  21. package/internal/useFetch.js.map +1 -1
  22. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  23. package/mcp-server/McpServerDetailView.js +145 -46
  24. package/mcp-server/McpServerDetailView.js.map +1 -1
  25. package/models/ModelRegistryContext.d.ts +2 -0
  26. package/models/ModelRegistryContext.d.ts.map +1 -1
  27. package/models/ModelRegistryContext.js +1 -0
  28. package/models/ModelRegistryContext.js.map +1 -1
  29. package/models/ModelSelector.d.ts.map +1 -1
  30. package/models/ModelSelector.js +2 -2
  31. package/models/ModelSelector.js.map +1 -1
  32. package/models/__tests__/useModelRegistry.test.js +4 -3
  33. package/models/__tests__/useModelRegistry.test.js.map +1 -1
  34. package/models/useModelRegistry.d.ts +2 -0
  35. package/models/useModelRegistry.d.ts.map +1 -1
  36. package/models/useModelRegistry.js +3 -2
  37. package/models/useModelRegistry.js.map +1 -1
  38. package/package.json +4 -4
  39. package/provider.d.ts.map +1 -1
  40. package/provider.js +69 -22
  41. package/provider.js.map +1 -1
  42. package/session/__tests__/session-spec-converters.test.d.ts +2 -0
  43. package/session/__tests__/session-spec-converters.test.d.ts.map +1 -0
  44. package/session/__tests__/session-spec-converters.test.js +162 -0
  45. package/session/__tests__/session-spec-converters.test.js.map +1 -0
  46. package/session/__tests__/useNewSessionFlow.test.js +2 -2
  47. package/session/__tests__/useNewSessionFlow.test.js.map +1 -1
  48. package/session/__tests__/usePersistedModel.test.js +1 -1
  49. package/session/__tests__/usePersistedModel.test.js.map +1 -1
  50. package/session/group-sessions.d.ts +17 -0
  51. package/session/group-sessions.d.ts.map +1 -1
  52. package/session/group-sessions.js +46 -0
  53. package/session/group-sessions.js.map +1 -1
  54. package/session/index.d.ts +4 -2
  55. package/session/index.d.ts.map +1 -1
  56. package/session/index.js +2 -1
  57. package/session/index.js.map +1 -1
  58. package/session/session-spec-converters.d.ts +24 -0
  59. package/session/session-spec-converters.d.ts.map +1 -0
  60. package/session/session-spec-converters.js +72 -0
  61. package/session/session-spec-converters.js.map +1 -0
  62. package/session/useSessionConversation.d.ts.map +1 -1
  63. package/session/useSessionConversation.js +1 -56
  64. package/session/useSessionConversation.js.map +1 -1
  65. package/session/useSessionPageFlow.d.ts +5 -0
  66. package/session/useSessionPageFlow.d.ts.map +1 -1
  67. package/session/useSessionPageFlow.js +20 -6
  68. package/session/useSessionPageFlow.js.map +1 -1
  69. package/session/useSessionSearch.d.ts +57 -0
  70. package/session/useSessionSearch.d.ts.map +1 -0
  71. package/session/useSessionSearch.js +94 -0
  72. package/session/useSessionSearch.js.map +1 -0
  73. package/src/composer/ContextChip.tsx +20 -11
  74. package/src/composer/SessionComposer.tsx +52 -3
  75. package/src/environment/usePersonalEnvironment.ts +1 -0
  76. package/src/index.ts +5 -0
  77. package/src/inline-edit/InlineEditKeyValue.tsx +23 -0
  78. package/src/internal/useFetch.ts +2 -2
  79. package/src/mcp-server/McpServerDetailView.tsx +429 -55
  80. package/src/models/ModelRegistryContext.ts +3 -0
  81. package/src/models/ModelSelector.tsx +25 -2
  82. package/src/models/__tests__/useModelRegistry.test.tsx +5 -3
  83. package/src/models/useModelRegistry.ts +5 -2
  84. package/src/provider.tsx +69 -18
  85. package/src/session/__tests__/session-spec-converters.test.ts +185 -0
  86. package/src/session/__tests__/useNewSessionFlow.test.tsx +2 -2
  87. package/src/session/__tests__/usePersistedModel.test.tsx +1 -1
  88. package/src/session/group-sessions.ts +65 -0
  89. package/src/session/index.ts +8 -2
  90. package/src/session/session-spec-converters.ts +86 -0
  91. package/src/session/useSessionConversation.ts +5 -64
  92. package/src/session/useSessionPageFlow.ts +28 -7
  93. package/src/session/useSessionSearch.ts +149 -0
  94. package/styles.css +1 -1
@@ -90,8 +90,10 @@ const TEST_REGISTRY_JSON = {
90
90
 
91
91
  const TEST_MODELS: readonly ModelInfo[] = parseRegistryJson(TEST_REGISTRY_JSON);
92
92
 
93
+ const noopRefetch = () => {};
94
+
93
95
  function createWrapper(models: readonly ModelInfo[] = TEST_MODELS) {
94
- const state: ModelRegistryState = { models, isLoading: false, error: null };
96
+ const state: ModelRegistryState = { models, isLoading: false, error: null, refetch: noopRefetch };
95
97
  return function Wrapper({ children }: { children: ReactNode }) {
96
98
  return (
97
99
  <ModelRegistryContext.Provider value={state}>
@@ -285,7 +287,7 @@ describe("useModelRegistry", () => {
285
287
 
286
288
  describe("loading state", () => {
287
289
  it("exposes isLoading from context", () => {
288
- const loadingState: ModelRegistryState = { models: [], isLoading: true, error: null };
290
+ const loadingState: ModelRegistryState = { models: [], isLoading: true, error: null, refetch: noopRefetch };
289
291
  const wrapper = ({ children }: { children: ReactNode }) => (
290
292
  <ModelRegistryContext.Provider value={loadingState}>
291
293
  {children}
@@ -298,7 +300,7 @@ describe("useModelRegistry", () => {
298
300
 
299
301
  it("exposes error from context", () => {
300
302
  const err = new Error("fetch failed");
301
- const errorState: ModelRegistryState = { models: [], isLoading: false, error: err };
303
+ const errorState: ModelRegistryState = { models: [], isLoading: false, error: err, refetch: noopRefetch };
302
304
  const wrapper = ({ children }: { children: ReactNode }) => (
303
305
  <ModelRegistryContext.Provider value={errorState}>
304
306
  {children}
@@ -55,6 +55,8 @@ export interface UseModelRegistryReturn {
55
55
  readonly isLoading: boolean;
56
56
  /** Non-null if the API fetch failed. Models will be empty in this case. */
57
57
  readonly error: Error | null;
58
+ /** Retry fetching the model registry. No-op while a fetch is in flight. */
59
+ readonly refetch: () => void;
58
60
  }
59
61
 
60
62
  /**
@@ -85,7 +87,7 @@ export interface UseModelRegistryReturn {
85
87
  */
86
88
  export function useModelRegistry(options?: UseModelRegistryOptions): UseModelRegistryReturn {
87
89
  const harness = options?.harness;
88
- const { models: allModels, isLoading, error } = useModelRegistryContext();
90
+ const { models: allModels, isLoading, error, refetch } = useModelRegistryContext();
89
91
 
90
92
  return useMemo(() => {
91
93
  const isUnified = harness === undefined;
@@ -143,6 +145,7 @@ export function useModelRegistry(options?: UseModelRegistryOptions): UseModelReg
143
145
  getByKey: (key: string) => byCompoundKey.get(key),
144
146
  isLoading,
145
147
  error,
148
+ refetch,
146
149
  };
147
- }, [harness, allModels, isLoading, error]);
150
+ }, [harness, allModels, isLoading, error, refetch]);
148
151
  }
package/src/provider.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useRef, useState, type ReactNode } from "react";
3
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
4
4
  import type { Stigmer, DeploymentMode } from "@stigmer/sdk";
5
5
  import { cn, resolvePresetClass } from "@stigmer/theme";
6
6
  import type { ThemePresetId } from "@stigmer/theme";
@@ -151,15 +151,23 @@ export function StigmerProvider({
151
151
  );
152
152
  }
153
153
 
154
+ const RETRY_DELAYS_MS = [1_000, 2_000, 4_000];
155
+ const TOKEN_POLL_INTERVAL_MS = 500;
156
+ const TOKEN_POLL_MAX_MS = 10_000;
157
+
154
158
  /**
155
- * Fetches the model registry from the authenticated API on mount and
156
- * caches the result for the lifetime of the provider.
159
+ * Fetches the model registry from the authenticated API and caches
160
+ * the result for the lifetime of the provider.
157
161
  *
158
- * Uses the client's `baseUrl` and `getAuthCredential()` so the fetch
159
- * is authenticated with the same token the SDK uses for all other calls.
162
+ * Handles the auth race condition where `StigmerProvider` mounts before
163
+ * PKCE authentication is established (e.g. desktop release builds with
164
+ * a fresh localStorage). When the initial token is `null`, polls for
165
+ * a valid token before giving up. Retries on transient failures with
166
+ * exponential backoff. Exposes `refetch` for manual retry from the UI.
160
167
  */
161
168
  function useModelRegistryFetch(client: Stigmer): ModelRegistryState {
162
- const [state, setState] = useState<ModelRegistryState>({
169
+ const [version, setVersion] = useState(0);
170
+ const [state, setState] = useState<Omit<ModelRegistryState, "refetch">>({
163
171
  models: [],
164
172
  isLoading: true,
165
173
  error: null,
@@ -168,31 +176,74 @@ function useModelRegistryFetch(client: Stigmer): ModelRegistryState {
168
176
  const clientRef = useRef(client);
169
177
  clientRef.current = client;
170
178
 
171
- useEffect(() => {
172
- let cancelled = false;
179
+ const fetchAttemptRef = useRef(0);
173
180
 
181
+ const doFetch = useCallback(async (signal: AbortSignal) => {
174
182
  const c = clientRef.current;
175
- c.getAuthCredential()
176
- .then((token) => fetchModelRegistry(c.baseUrl, token, c.fetch))
177
- .then((models) => {
178
- if (!cancelled) {
183
+ let token = await c.getAuthCredential();
184
+
185
+ if (!token) {
186
+ const start = Date.now();
187
+ while (!token && Date.now() - start < TOKEN_POLL_MAX_MS) {
188
+ if (signal.aborted) return;
189
+ await new Promise((r) => setTimeout(r, TOKEN_POLL_INTERVAL_MS));
190
+ if (signal.aborted) return;
191
+ token = await c.getAuthCredential();
192
+ }
193
+ }
194
+
195
+ return fetchModelRegistry(c.baseUrl, token, c.fetch);
196
+ }, []);
197
+
198
+ useEffect(() => {
199
+ const controller = new AbortController();
200
+ const { signal } = controller;
201
+
202
+ fetchAttemptRef.current = 0;
203
+
204
+ const attempt = async () => {
205
+ if (signal.aborted) return;
206
+
207
+ setState((prev) => (prev.isLoading ? prev : { ...prev, isLoading: true }));
208
+
209
+ try {
210
+ const models = await doFetch(signal);
211
+ if (!signal.aborted && models) {
179
212
  setState({ models, isLoading: false, error: null });
213
+ fetchAttemptRef.current = 0;
180
214
  }
181
- })
182
- .catch((err: unknown) => {
183
- if (!cancelled) {
215
+ } catch (err: unknown) {
216
+ if (signal.aborted) return;
217
+
218
+ const retryIdx = fetchAttemptRef.current;
219
+ fetchAttemptRef.current = retryIdx + 1;
220
+
221
+ if (retryIdx < RETRY_DELAYS_MS.length) {
222
+ setTimeout(() => { if (!signal.aborted) attempt(); }, RETRY_DELAYS_MS[retryIdx]);
223
+ } else {
184
224
  setState({
185
225
  models: [],
186
226
  isLoading: false,
187
227
  error: err instanceof Error ? err : new Error(String(err)),
188
228
  });
189
229
  }
190
- });
230
+ }
231
+ };
232
+
233
+ attempt();
234
+
235
+ return () => { controller.abort(); };
236
+ }, [doFetch, version]);
191
237
 
192
- return () => { cancelled = true; };
238
+ const refetch = useCallback(() => {
239
+ setState((prev) => {
240
+ if (prev.isLoading) return prev;
241
+ return { ...prev, isLoading: true, error: null };
242
+ });
243
+ setVersion((v) => v + 1);
193
244
  }, []);
194
245
 
195
- return state;
246
+ return { ...state, refetch };
196
247
  }
197
248
 
198
249
  /**
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ specWorkspaceToInput,
4
+ specMcpUsagesToInput,
5
+ specSkillRefsToInput,
6
+ } from "../session-spec-converters";
7
+ import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
8
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
9
+
10
+ type SessionSpec = Session["spec"];
11
+
12
+ function makeSpec(overrides: Partial<NonNullable<SessionSpec>> = {}): SessionSpec {
13
+ return {
14
+ agentInstanceId: "",
15
+ subject: "",
16
+ threadId: "",
17
+ sandboxId: "",
18
+ metadata: {},
19
+ workspaceEntries: [],
20
+ mcpServerUsages: [],
21
+ skillRefs: [],
22
+ runnerId: "",
23
+ harness: 0,
24
+ cursorMode: 0,
25
+ ...overrides,
26
+ } as unknown as SessionSpec;
27
+ }
28
+
29
+ describe("specWorkspaceToInput", () => {
30
+ it("returns undefined for undefined spec", () => {
31
+ expect(specWorkspaceToInput(undefined)).toBeUndefined();
32
+ });
33
+
34
+ it("returns empty array for spec with no workspace entries", () => {
35
+ expect(specWorkspaceToInput(makeSpec())).toEqual([]);
36
+ });
37
+
38
+ it("converts git repo entries", () => {
39
+ const spec = makeSpec({
40
+ workspaceEntries: [
41
+ {
42
+ name: "my-repo",
43
+ source: {
44
+ source: {
45
+ case: "gitRepo" as const,
46
+ value: { url: "https://github.com/org/repo", branch: "main", commit: "", depth: 0 },
47
+ },
48
+ },
49
+ },
50
+ ] as unknown as SessionSpec extends undefined ? never : NonNullable<SessionSpec>["workspaceEntries"],
51
+ });
52
+
53
+ const result = specWorkspaceToInput(spec);
54
+ expect(result).toEqual([
55
+ {
56
+ name: "my-repo",
57
+ source: {
58
+ gitRepo: {
59
+ url: "https://github.com/org/repo",
60
+ branch: "main",
61
+ commit: undefined,
62
+ depth: undefined,
63
+ },
64
+ },
65
+ },
66
+ ]);
67
+ });
68
+
69
+ it("converts local path entries", () => {
70
+ const spec = makeSpec({
71
+ workspaceEntries: [
72
+ {
73
+ name: "local",
74
+ source: {
75
+ source: {
76
+ case: "localPath" as const,
77
+ value: { path: "/home/user/project" },
78
+ },
79
+ },
80
+ },
81
+ ] as unknown as SessionSpec extends undefined ? never : NonNullable<SessionSpec>["workspaceEntries"],
82
+ });
83
+
84
+ const result = specWorkspaceToInput(spec);
85
+ expect(result).toEqual([
86
+ {
87
+ name: "local",
88
+ source: {
89
+ localPath: { path: "/home/user/project" },
90
+ },
91
+ },
92
+ ]);
93
+ });
94
+ });
95
+
96
+ describe("specMcpUsagesToInput", () => {
97
+ it("returns undefined for undefined spec", () => {
98
+ expect(specMcpUsagesToInput(undefined)).toBeUndefined();
99
+ });
100
+
101
+ it("returns empty array for spec with no MCP server usages", () => {
102
+ expect(specMcpUsagesToInput(makeSpec())).toEqual([]);
103
+ });
104
+
105
+ it("converts MCP server usages with ref and enabled tools", () => {
106
+ const spec = makeSpec({
107
+ mcpServerUsages: [
108
+ {
109
+ mcpServerRef: {
110
+ org: "acme",
111
+ slug: "github-tools",
112
+ version: "v1",
113
+ kind: ApiResourceKind.mcp_server,
114
+ },
115
+ enabledTools: ["create_issue", "list_prs"],
116
+ toolApprovalOverrides: [],
117
+ },
118
+ ] as unknown as NonNullable<SessionSpec>["mcpServerUsages"],
119
+ });
120
+
121
+ const result = specMcpUsagesToInput(spec);
122
+ expect(result).toEqual([
123
+ {
124
+ mcpServerRef: {
125
+ org: "acme",
126
+ slug: "github-tools",
127
+ version: "v1",
128
+ kind: ApiResourceKind.mcp_server,
129
+ },
130
+ enabledTools: ["create_issue", "list_prs"],
131
+ toolApprovalOverrides: undefined,
132
+ },
133
+ ]);
134
+ });
135
+
136
+ it("converts MCP server usages with tool approval overrides", () => {
137
+ const spec = makeSpec({
138
+ mcpServerUsages: [
139
+ {
140
+ mcpServerRef: {
141
+ org: "acme",
142
+ slug: "shell",
143
+ version: "",
144
+ kind: ApiResourceKind.mcp_server,
145
+ },
146
+ enabledTools: [],
147
+ toolApprovalOverrides: [
148
+ { toolName: "exec", requiresApproval: true, message: "Dangerous" },
149
+ ],
150
+ },
151
+ ] as unknown as NonNullable<SessionSpec>["mcpServerUsages"],
152
+ });
153
+
154
+ const result = specMcpUsagesToInput(spec);
155
+ expect(result).toHaveLength(1);
156
+ expect(result![0].toolApprovalOverrides).toEqual([
157
+ { toolName: "exec", requiresApproval: true, message: "Dangerous" },
158
+ ]);
159
+ });
160
+ });
161
+
162
+ describe("specSkillRefsToInput", () => {
163
+ it("returns undefined for undefined spec", () => {
164
+ expect(specSkillRefsToInput(undefined)).toBeUndefined();
165
+ });
166
+
167
+ it("returns empty array for spec with no skill refs", () => {
168
+ expect(specSkillRefsToInput(makeSpec())).toEqual([]);
169
+ });
170
+
171
+ it("converts skill references", () => {
172
+ const spec = makeSpec({
173
+ skillRefs: [
174
+ { org: "acme", slug: "code-review", version: "v2", kind: ApiResourceKind.skill },
175
+ { org: "acme", slug: "testing", version: "", kind: ApiResourceKind.skill },
176
+ ] as unknown as NonNullable<SessionSpec>["skillRefs"],
177
+ });
178
+
179
+ const result = specSkillRefsToInput(spec);
180
+ expect(result).toEqual([
181
+ { org: "acme", slug: "code-review", version: "v2", kind: ApiResourceKind.skill },
182
+ { org: "acme", slug: "testing", version: undefined, kind: ApiResourceKind.skill },
183
+ ]);
184
+ });
185
+ });
@@ -82,7 +82,7 @@ const TEST_MODELS = parseRegistryJson({
82
82
  });
83
83
 
84
84
  function createWrapper() {
85
- const state: ModelRegistryState = { models: TEST_MODELS, isLoading: false, error: null };
85
+ const state: ModelRegistryState = { models: TEST_MODELS, isLoading: false, error: null, refetch: () => {} };
86
86
  return function Wrapper({ children }: { children: ReactNode }) {
87
87
  return (
88
88
  <ModelRegistryContext.Provider value={state}>
@@ -284,7 +284,7 @@ describe("useNewSessionFlow", () => {
284
284
  describe("model validation timing", () => {
285
285
  it("does not restore model while registry is loading", () => {
286
286
  localStorage.setItem(STORAGE_KEY_MODEL_NATIVE, DEFAULT_MODEL_ID);
287
- const loadingState: ModelRegistryState = { models: [], isLoading: true, error: null };
287
+ const loadingState: ModelRegistryState = { models: [], isLoading: true, error: null, refetch: () => {} };
288
288
 
289
289
  function LoadingWrapper({ children }: { children: ReactNode }) {
290
290
  return (
@@ -14,7 +14,7 @@ const TEST_MODELS = parseRegistryJson({
14
14
  });
15
15
 
16
16
  function createWrapper() {
17
- const state: ModelRegistryState = { models: TEST_MODELS, isLoading: false, error: null };
17
+ const state: ModelRegistryState = { models: TEST_MODELS, isLoading: false, error: null, refetch: () => {} };
18
18
  return function Wrapper({ children }: { children: ReactNode }) {
19
19
  return (
20
20
  <ModelRegistryContext.Provider value={state}>
@@ -1,4 +1,5 @@
1
1
  import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
2
+ import type { SearchResult } from "@stigmer/protos/ai/stigmer/search/v1/io_pb";
2
3
  import { timestampDate } from "@bufbuild/protobuf/wkt";
3
4
 
4
5
  /** A time-based group of sessions produced by {@link groupSessionsByTime}. */
@@ -9,11 +10,24 @@ export interface SessionGroup {
9
10
  readonly sessions: readonly Session[];
10
11
  }
11
12
 
13
+ /** A time-based group of search results produced by {@link groupSearchResultsByTime}. */
14
+ export interface SearchResultGroup {
15
+ /** Display label for this group (e.g. "Today", "Yesterday"). */
16
+ readonly label: string;
17
+ /** Search results belonging to this group, in their original input order. */
18
+ readonly entries: readonly SearchResult[];
19
+ }
20
+
12
21
  interface Bucket {
13
22
  label: string;
14
23
  sessions: Session[];
15
24
  }
16
25
 
26
+ interface SearchResultBucket {
27
+ label: string;
28
+ entries: SearchResult[];
29
+ }
30
+
17
31
  /**
18
32
  * Groups sessions by their creation timestamp into time-based buckets:
19
33
  * "Today", "Yesterday", "Previous 7 Days", "Previous 30 Days", "Older".
@@ -91,6 +105,57 @@ export function groupSessionsByTime(
91
105
  return buckets.filter((b) => b.sessions.length > 0);
92
106
  }
93
107
 
108
+ /**
109
+ * Groups SearchResult entries by their `createdAt` timestamp into time-based
110
+ * buckets, identical to {@link groupSessionsByTime} but for lightweight
111
+ * search results.
112
+ *
113
+ * @param entries - SearchResult entries (from SearchService session search).
114
+ * @param now - Reference time for grouping; defaults to current time.
115
+ */
116
+ export function groupSearchResultsByTime(
117
+ entries: readonly SearchResult[],
118
+ now?: Date,
119
+ ): readonly SearchResultGroup[] {
120
+ const ref = now ?? new Date();
121
+ const todayStart = startOfDay(ref);
122
+ const yesterdayStart = addDays(todayStart, -1);
123
+ const sevenDaysAgo = addDays(todayStart, -6);
124
+ const thirtyDaysAgo = addDays(todayStart, -29);
125
+
126
+ const buckets: SearchResultBucket[] = [
127
+ { label: "Today", entries: [] },
128
+ { label: "Yesterday", entries: [] },
129
+ { label: "Previous 7 Days", entries: [] },
130
+ { label: "Previous 30 Days", entries: [] },
131
+ { label: "Older", entries: [] },
132
+ ];
133
+
134
+ for (const entry of entries) {
135
+ const ts = entry.createdAt;
136
+ const date = ts ? timestampDate(ts) : null;
137
+
138
+ if (!date) {
139
+ buckets[buckets.length - 1].entries.push(entry);
140
+ continue;
141
+ }
142
+
143
+ if (date >= todayStart) {
144
+ buckets[0].entries.push(entry);
145
+ } else if (date >= yesterdayStart) {
146
+ buckets[1].entries.push(entry);
147
+ } else if (date >= sevenDaysAgo) {
148
+ buckets[2].entries.push(entry);
149
+ } else if (date >= thirtyDaysAgo) {
150
+ buckets[3].entries.push(entry);
151
+ } else {
152
+ buckets[4].entries.push(entry);
153
+ }
154
+ }
155
+
156
+ return buckets.filter((b) => b.entries.length > 0);
157
+ }
158
+
94
159
  function startOfDay(date: Date): Date {
95
160
  const d = new Date(date);
96
161
  d.setHours(0, 0, 0, 0);
@@ -76,8 +76,14 @@ export type {
76
76
  DraftParams,
77
77
  } from "./draft";
78
78
 
79
- export { groupSessionsByTime } from "./group-sessions";
80
- export type { SessionGroup } from "./group-sessions";
79
+ export { groupSessionsByTime, groupSearchResultsByTime } from "./group-sessions";
80
+ export type { SessionGroup, SearchResultGroup } from "./group-sessions";
81
+
82
+ export { useSessionSearch } from "./useSessionSearch";
83
+ export type {
84
+ UseSessionSearchOptions,
85
+ UseSessionSearchReturn,
86
+ } from "./useSessionSearch";
81
87
 
82
88
  // Session utilities (re-exported from @stigmer/sdk)
83
89
  export { PENDING_SUBJECT, resolvedSubject } from "@stigmer/sdk";
@@ -0,0 +1,86 @@
1
+ import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
2
+ import type {
3
+ McpServerUsageInput,
4
+ ResourceRef,
5
+ WorkspaceEntryInput,
6
+ } from "@stigmer/sdk";
7
+
8
+ /**
9
+ * Convert proto workspace entries back to SDK input format.
10
+ *
11
+ * Used by `useSessionConversation` (session update with replace semantics)
12
+ * and `useSessionPageFlow` (hydrating composer state from a loaded session).
13
+ */
14
+ export function specWorkspaceToInput(
15
+ spec: Session["spec"],
16
+ ): WorkspaceEntryInput[] | undefined {
17
+ return spec?.workspaceEntries?.map((e): WorkspaceEntryInput => {
18
+ if (e.source?.source.case === "gitRepo") {
19
+ const v = e.source.source.value;
20
+ return {
21
+ name: e.name || undefined,
22
+ source: {
23
+ gitRepo: {
24
+ url: v.url,
25
+ branch: v.branch || undefined,
26
+ commit: v.commit || undefined,
27
+ depth: v.depth || undefined,
28
+ },
29
+ },
30
+ };
31
+ }
32
+ if (e.source?.source.case === "localPath") {
33
+ return {
34
+ name: e.name || undefined,
35
+ source: {
36
+ localPath: { path: e.source.source.value.path || undefined },
37
+ },
38
+ };
39
+ }
40
+ return { name: e.name || undefined, source: {} };
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Convert proto MCP server usages back to SDK input format.
46
+ *
47
+ * Used by `useSessionConversation` (session update with replace semantics)
48
+ * and `useSessionPageFlow` (hydrating composer state from a loaded session).
49
+ */
50
+ export function specMcpUsagesToInput(
51
+ spec: Session["spec"],
52
+ ): McpServerUsageInput[] | undefined {
53
+ return spec?.mcpServerUsages?.map((u) => ({
54
+ mcpServerRef: {
55
+ org: u.mcpServerRef?.org ?? "",
56
+ slug: u.mcpServerRef?.slug ?? "",
57
+ version: u.mcpServerRef?.version || undefined,
58
+ kind: u.mcpServerRef?.kind,
59
+ },
60
+ enabledTools: u.enabledTools?.length ? [...u.enabledTools] : undefined,
61
+ toolApprovalOverrides: u.toolApprovalOverrides?.length
62
+ ? u.toolApprovalOverrides.map((o) => ({
63
+ toolName: o.toolName || undefined,
64
+ requiresApproval: o.requiresApproval || undefined,
65
+ message: o.message || undefined,
66
+ }))
67
+ : undefined,
68
+ }));
69
+ }
70
+
71
+ /**
72
+ * Convert proto skill references back to SDK input format.
73
+ *
74
+ * Used by `useSessionConversation` (session update with replace semantics)
75
+ * and `useSessionPageFlow` (hydrating composer state from a loaded session).
76
+ */
77
+ export function specSkillRefsToInput(
78
+ spec: Session["spec"],
79
+ ): ResourceRef[] | undefined {
80
+ return spec?.skillRefs?.map((r) => ({
81
+ org: r.org ?? "",
82
+ slug: r.slug ?? "",
83
+ version: r.version || undefined,
84
+ kind: r.kind,
85
+ }));
86
+ }
@@ -24,6 +24,11 @@ import { useSubmitApproval } from "../execution/useSubmitApproval";
24
24
  import { useSession } from "./useSession";
25
25
  import { useSessionExecutions } from "./useSessionExecutions";
26
26
  import { useUpdateSession } from "./useUpdateSession";
27
+ import {
28
+ specWorkspaceToInput,
29
+ specMcpUsagesToInput,
30
+ specSkillRefsToInput,
31
+ } from "./session-spec-converters";
27
32
 
28
33
  /**
29
34
  * Options for {@link UseSessionConversationReturn.sendFollowUp}.
@@ -479,67 +484,3 @@ function buildUpdateInput(
479
484
  };
480
485
  }
481
486
 
482
- /** Convert proto workspace entries back to SDK input format. */
483
- function specWorkspaceToInput(
484
- spec: Session["spec"],
485
- ): WorkspaceEntryInput[] | undefined {
486
- return spec?.workspaceEntries?.map((e): WorkspaceEntryInput => {
487
- if (e.source?.source.case === "gitRepo") {
488
- const v = e.source.source.value;
489
- return {
490
- name: e.name || undefined,
491
- source: {
492
- gitRepo: {
493
- url: v.url,
494
- branch: v.branch || undefined,
495
- commit: v.commit || undefined,
496
- depth: v.depth || undefined,
497
- },
498
- },
499
- };
500
- }
501
- if (e.source?.source.case === "localPath") {
502
- return {
503
- name: e.name || undefined,
504
- source: {
505
- localPath: { path: e.source.source.value.path || undefined },
506
- },
507
- };
508
- }
509
- return { name: e.name || undefined, source: {} };
510
- });
511
- }
512
-
513
- /** Convert proto MCP server usages back to SDK input format. */
514
- function specMcpUsagesToInput(
515
- spec: Session["spec"],
516
- ): McpServerUsageInput[] | undefined {
517
- return spec?.mcpServerUsages?.map((u) => ({
518
- mcpServerRef: {
519
- org: u.mcpServerRef?.org ?? "",
520
- slug: u.mcpServerRef?.slug ?? "",
521
- version: u.mcpServerRef?.version || undefined,
522
- kind: u.mcpServerRef?.kind,
523
- },
524
- enabledTools: u.enabledTools?.length ? [...u.enabledTools] : undefined,
525
- toolApprovalOverrides: u.toolApprovalOverrides?.length
526
- ? u.toolApprovalOverrides.map((o) => ({
527
- toolName: o.toolName || undefined,
528
- requiresApproval: o.requiresApproval || undefined,
529
- message: o.message || undefined,
530
- }))
531
- : undefined,
532
- }));
533
- }
534
-
535
- /** Convert proto skill references back to SDK input format. */
536
- function specSkillRefsToInput(
537
- spec: Session["spec"],
538
- ): ResourceRef[] | undefined {
539
- return spec?.skillRefs?.map((r) => ({
540
- org: r.org ?? "",
541
- slug: r.slug ?? "",
542
- version: r.version || undefined,
543
- kind: r.kind,
544
- }));
545
- }