@xenide-io/the-old-ui-theme 0.6.9 → 0.6.11

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.
@@ -31,11 +31,72 @@ import {
31
31
  const SuiteAiBlockNoteMessage = lazy(() => import("./ai-message-blocknote"));
32
32
 
33
33
  export const SUITE_OPEN_ASK_AI_EVENT = "shellstack:open-ask-ai";
34
+ export const SUITE_ASK_AI_OPEN_KEY = "shellstack:shelly-open";
35
+ export const SUITE_ASK_AI_OPEN_QUERY = "shelly";
36
+ export const SUITE_MUTATED_EVENT = "shellstack:suite-mutated";
37
+ export const TURTLETIME_TIMER_MUTATED_EVENT = "tt-timer-mutated";
34
38
 
35
39
  export type SuiteAskAiOpenDetail = {
36
40
  prompt?: string;
37
41
  };
38
42
 
43
+ export function persistSuiteAskAiOpen(open: boolean): void {
44
+ if (typeof window === "undefined") return;
45
+ try {
46
+ if (open) sessionStorage.setItem(SUITE_ASK_AI_OPEN_KEY, "1");
47
+ else sessionStorage.removeItem(SUITE_ASK_AI_OPEN_KEY);
48
+ } catch {
49
+ // private mode / blocked storage
50
+ }
51
+ }
52
+
53
+ export function readSuiteAskAiOpen(): boolean {
54
+ if (typeof window === "undefined") return false;
55
+ try {
56
+ return sessionStorage.getItem(SUITE_ASK_AI_OPEN_KEY) === "1";
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /** Stamp a destination path so the other app opens Shelly after SSO. */
63
+ export function pathWithSuiteAskAiOpen(path: string): string {
64
+ const url = new URL((path || "/").trim() || "/", "http://local.invalid");
65
+ url.searchParams.set(SUITE_ASK_AI_OPEN_QUERY, "1");
66
+ return `${url.pathname}${url.search}${url.hash}`;
67
+ }
68
+
69
+ /** Open Shelly only after a `?shelly=1` handoff — not a leftover session flag. */
70
+ export function consumeSuiteAskAiOpenFromLocation(): boolean {
71
+ if (typeof window === "undefined") return false;
72
+ const url = new URL(window.location.href);
73
+ if (url.searchParams.get(SUITE_ASK_AI_OPEN_QUERY) !== "1") return false;
74
+ persistSuiteAskAiOpen(true);
75
+ url.searchParams.delete(SUITE_ASK_AI_OPEN_QUERY);
76
+ window.history.replaceState(
77
+ window.history.state,
78
+ "",
79
+ `${url.pathname}${url.search}${url.hash}`,
80
+ );
81
+ return true;
82
+ }
83
+
84
+ export type SuiteAiActionStatus =
85
+ | "proposed"
86
+ | "applied"
87
+ | "cancelled"
88
+ | "failed";
89
+
90
+ export interface SuiteAiAction {
91
+ id: string;
92
+ kind: string;
93
+ status: SuiteAiActionStatus;
94
+ summary: string;
95
+ href?: string;
96
+ error?: string;
97
+ payload?: Record<string, unknown>;
98
+ }
99
+
39
100
  let pendingAskAiPrompt: string | null = null;
40
101
 
41
102
  /** Open the suite Ask AI panel from anywhere (Today card, command palette, …). */
@@ -65,6 +126,25 @@ export interface SuiteAiChatMessage {
65
126
  role: "user" | "assistant";
66
127
  content: string;
67
128
  created_at?: string;
129
+ actions?: SuiteAiAction[];
130
+ }
131
+
132
+ function notifySuiteMutated(actions?: SuiteAiAction[]): void {
133
+ if (typeof window === "undefined" || !actions?.length) return;
134
+ const kinds = actions.map((action) => action.kind);
135
+ if (
136
+ kinds.some(
137
+ (kind) =>
138
+ kind.includes("timer") ||
139
+ kind.includes("time") ||
140
+ kind.includes("hours"),
141
+ )
142
+ ) {
143
+ window.dispatchEvent(new Event(TURTLETIME_TIMER_MUTATED_EVENT));
144
+ }
145
+ window.dispatchEvent(
146
+ new CustomEvent(SUITE_MUTATED_EVENT, { detail: { kinds } }),
147
+ );
68
148
  }
69
149
 
70
150
  /**
@@ -84,6 +164,8 @@ export function SuiteAiPanel({
84
164
  onOpenChange,
85
165
  suiteAppBases,
86
166
  onSwitchApp,
167
+ onSameAppNavigate,
168
+ resolveAction,
87
169
  }: {
88
170
  presets?: SuiteAiPreset[];
89
171
  emptyState?: ReactNode;
@@ -105,12 +187,20 @@ export function SuiteAiPanel({
105
187
  suiteAppBases?: Partial<Record<SuiteAppSlug, string>>;
106
188
  /** Cross-app SSO. Same-app links just navigate to `path`. */
107
189
  onSwitchApp?: (app: SuiteAppSlug, path: string) => void;
190
+ /** Client-side same-app route change — keeps Shelly mounted. */
191
+ onSameAppNavigate?: (path: string) => void;
192
+ resolveAction?: (
193
+ id: string,
194
+ decision: "confirm" | "cancel",
195
+ ) => Promise<{ messages: SuiteAiChatMessage[] }>;
108
196
  }) {
109
197
  const inputId = useId();
110
198
  const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
199
+ const [resolvingId, setResolvingId] = useState("");
111
200
  const open = openProp ?? uncontrolledOpen;
112
201
  const setOpen = useCallback(
113
202
  (next: boolean) => {
203
+ persistSuiteAskAiOpen(next);
114
204
  if (openProp === undefined) setUncontrolledOpen(next);
115
205
  onOpenChange?.(next);
116
206
  },
@@ -215,7 +305,11 @@ export function SuiteAiPanel({
215
305
  signal: controller.signal,
216
306
  });
217
307
  if (controller.signal.aborted) return;
218
- setMessages(result.messages ?? []);
308
+ const nextMessages = result.messages ?? [];
309
+ setMessages(nextMessages);
310
+ notifySuiteMutated(
311
+ nextMessages.flatMap((message) => message.actions ?? []),
312
+ );
219
313
  } catch (err) {
220
314
  if (controller.signal.aborted) return;
221
315
  setMessages((prev) =>
@@ -264,6 +358,29 @@ export function SuiteAiPanel({
264
358
  }
265
359
  }
266
360
 
361
+ async function handleResolve(
362
+ actionId: string,
363
+ decision: "confirm" | "cancel",
364
+ ) {
365
+ if (!resolveAction || resolvingId) return;
366
+ setResolvingId(actionId);
367
+ setError("");
368
+ try {
369
+ const result = await resolveAction(actionId, decision);
370
+ const nextMessages = result.messages ?? [];
371
+ setMessages(nextMessages);
372
+ notifySuiteMutated(
373
+ nextMessages.flatMap((message) => message.actions ?? []),
374
+ );
375
+ } catch (err) {
376
+ setError(
377
+ err instanceof Error ? err.message : "Could not apply that action.",
378
+ );
379
+ } finally {
380
+ setResolvingId("");
381
+ }
382
+ }
383
+
267
384
  function onConversationClick(event: ReactMouseEvent<HTMLDivElement>) {
268
385
  if (event.defaultPrevented) return;
269
386
  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
@@ -280,12 +397,15 @@ export function SuiteAiPanel({
280
397
  );
281
398
  if (!match) return;
282
399
  event.preventDefault();
400
+ persistSuiteAskAiOpen(true);
283
401
  if (match.sameApp) {
284
- window.location.assign(match.path);
402
+ if (onSameAppNavigate) onSameAppNavigate(match.path);
403
+ else window.location.assign(pathWithSuiteAskAiOpen(match.path));
285
404
  return;
286
405
  }
287
- if (onSwitchApp) onSwitchApp(match.slug, match.path);
288
- else window.location.assign(anchor.href);
406
+ const openPath = pathWithSuiteAskAiOpen(match.path);
407
+ if (onSwitchApp) onSwitchApp(match.slug, openPath);
408
+ else window.location.assign(new URL(openPath, href).toString());
289
409
  }
290
410
 
291
411
  if (!open) return null;
@@ -391,16 +511,74 @@ export function SuiteAiPanel({
391
511
  >
392
512
  <Icon className="h-3.5 w-3.5" />
393
513
  </span>
394
- <div className="min-w-0 max-w-[85%] rounded-2xl rounded-bl-md bg-ph-muted px-3 py-2 text-sm leading-relaxed text-ph-ink">
395
- <Suspense
396
- fallback={
397
- <p className="whitespace-pre-wrap break-words">
398
- {message.content}
399
- </p>
400
- }
401
- >
402
- <SuiteAiBlockNoteMessage markdown={message.content} />
403
- </Suspense>
514
+ <div className="min-w-0 max-w-[85%] space-y-2">
515
+ <div className="rounded-2xl rounded-bl-md bg-ph-muted px-3 py-2 text-sm leading-relaxed text-ph-ink">
516
+ <Suspense
517
+ fallback={
518
+ <p className="whitespace-pre-wrap break-words">
519
+ {message.content}
520
+ </p>
521
+ }
522
+ >
523
+ <SuiteAiBlockNoteMessage markdown={message.content} />
524
+ </Suspense>
525
+ </div>
526
+ {(message.actions ?? []).map((action) => (
527
+ <div
528
+ key={action.id}
529
+ data-test="ask-ai-action"
530
+ data-status={action.status}
531
+ className="rounded-xl border border-ph-border bg-ph-surface px-3 py-2 text-sm text-ph-ink"
532
+ >
533
+ <p className="font-medium">{action.summary}</p>
534
+ {action.status === "proposed" ? (
535
+ <div className="mt-2 flex flex-wrap gap-2">
536
+ <button
537
+ type="button"
538
+ data-test="ask-ai-action-confirm"
539
+ disabled={Boolean(resolvingId) || !resolveAction}
540
+ onClick={() => void handleResolve(action.id, "confirm")}
541
+ className="inline-flex min-h-8 items-center rounded-lg bg-ph-brand px-2.5 text-xs font-medium text-[color:var(--ph-on-accent)] transition-opacity hover:bg-ph-brand-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus disabled:opacity-40"
542
+ >
543
+ {resolvingId === action.id ? "Working…" : "Confirm"}
544
+ </button>
545
+ <button
546
+ type="button"
547
+ data-test="ask-ai-action-cancel"
548
+ disabled={Boolean(resolvingId) || !resolveAction}
549
+ onClick={() => void handleResolve(action.id, "cancel")}
550
+ className="inline-flex min-h-8 items-center rounded-lg border border-ph-border bg-ph-surface px-2.5 text-xs font-medium text-ph-ink transition-colors hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus disabled:opacity-40"
551
+ >
552
+ Cancel
553
+ </button>
554
+ </div>
555
+ ) : null}
556
+ {action.status === "applied" ? (
557
+ <p className="mt-1 text-xs text-ph-subtle">
558
+ Done
559
+ {action.href ? (
560
+ <>
561
+ {" · "}
562
+ <a
563
+ href={action.href}
564
+ className="text-ph-brand underline-offset-2 hover:underline"
565
+ >
566
+ Open
567
+ </a>
568
+ </>
569
+ ) : null}
570
+ </p>
571
+ ) : null}
572
+ {action.status === "cancelled" ? (
573
+ <p className="mt-1 text-xs text-ph-subtle">Cancelled</p>
574
+ ) : null}
575
+ {action.status === "failed" ? (
576
+ <p className="mt-1 text-xs text-ph-danger">
577
+ {action.error || "Could not apply that."}
578
+ </p>
579
+ ) : null}
580
+ </div>
581
+ ))}
404
582
  </div>
405
583
  </div>
406
584
  );
@@ -0,0 +1,33 @@
1
+ import { cleanup, render } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import { SuiteMobileDrawer } from "./suite-mobile-drawer";
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe("SuiteMobileDrawer", () => {
9
+ it("puts one full-height scroller under the close row", () => {
10
+ render(
11
+ <SuiteMobileDrawer
12
+ open
13
+ onClose={() => {}}
14
+ showCloseButton
15
+ title="Kraken"
16
+ >
17
+ <p>Documents</p>
18
+ </SuiteMobileDrawer>,
19
+ );
20
+
21
+ const body = document.querySelector(
22
+ '[data-test="suite-mobile-drawer-body"]',
23
+ );
24
+ expect(body).not.toBeNull();
25
+ expect(body).toHaveClass(
26
+ "min-h-0",
27
+ "flex-1",
28
+ "overflow-y-auto",
29
+ "suite-scroll-lock",
30
+ );
31
+ expect(body?.previousElementSibling).toHaveClass("h-14", "shrink-0");
32
+ });
33
+ });
@@ -133,7 +133,7 @@ export function SuiteMobileDrawer({
133
133
  tabIndex={-1}
134
134
  data-test={panelDataTest}
135
135
  className={cn(
136
- "suite-scroll-lock relative flex h-full w-[86%] max-w-72 flex-col overflow-hidden bg-ph-surface shadow-xl outline-none motion-safe:transition-transform motion-safe:ease-spring-fast",
136
+ "relative flex h-dvh max-h-dvh w-[86%] max-w-72 flex-col overflow-hidden bg-ph-surface shadow-xl outline-none motion-safe:transition-transform motion-safe:ease-spring-fast",
137
137
  side === "right" && "ml-auto",
138
138
  shown
139
139
  ? "translate-x-0"
@@ -168,7 +168,14 @@ export function SuiteMobileDrawer({
168
168
  </button>
169
169
  </div>
170
170
  ) : null}
171
- {children}
171
+ {/* One column scroller, same as desktop SuiteSidebar body. Nested
172
+ flex-1 overflow-y-auto regions trap the tree in a leftover strip. */}
173
+ <div
174
+ data-test="suite-mobile-drawer-body"
175
+ className="suite-scroll-lock flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-[max(0.75rem,env(safe-area-inset-bottom))]"
176
+ >
177
+ {children}
178
+ </div>
172
179
  </aside>
173
180
  </div>
174
181
  );
@@ -1,12 +1,15 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
- import { useEffect, type ReactNode } from "react";
4
+ import { useCallback, useEffect, type ReactNode } from "react";
5
5
  import { NavArrowLeft, Sparks } from "iconoir-react";
6
6
 
7
7
  import { Tooltip } from "../../components/ui/Tooltip";
8
8
  import { cn } from "../lib/cn";
9
- import { SUITE_OPEN_ASK_AI_EVENT } from "./ai-panel";
9
+ import {
10
+ persistSuiteAskAiOpen,
11
+ SUITE_OPEN_ASK_AI_EVENT,
12
+ } from "./ai-panel";
10
13
  import type { SuiteNavIcon } from "./suite-bottom-nav";
11
14
 
12
15
  type CollapsedNode = ReactNode | ((collapsed: boolean) => ReactNode);
@@ -84,32 +87,40 @@ export function SuiteSidebar({
84
87
  const showAskAi = Boolean(onAskAiOpenChange && askAi);
85
88
  const chatOpen = showAskAi && askAiOpen && !collapsed;
86
89
 
90
+ const setAskAi = useCallback(
91
+ (next: boolean) => {
92
+ persistSuiteAskAiOpen(next);
93
+ onAskAiOpenChange?.(next);
94
+ },
95
+ [onAskAiOpenChange],
96
+ );
97
+
87
98
  useEffect(() => {
88
99
  if (!onAskAiOpenChange) return;
89
100
  function onOpen() {
90
101
  onRequestExpand?.();
91
- onAskAiOpenChange?.(true);
102
+ setAskAi(true);
92
103
  }
93
104
  window.addEventListener(SUITE_OPEN_ASK_AI_EVENT, onOpen);
94
105
  return () => window.removeEventListener(SUITE_OPEN_ASK_AI_EVENT, onOpen);
95
- }, [onAskAiOpenChange, onRequestExpand]);
106
+ }, [onAskAiOpenChange, onRequestExpand, setAskAi]);
96
107
 
97
108
  useEffect(() => {
98
109
  if (!chatOpen) return;
99
110
  function onKey(event: KeyboardEvent) {
100
- if (event.key === "Escape") onAskAiOpenChange?.(false);
111
+ if (event.key === "Escape") setAskAi(false);
101
112
  }
102
113
  window.addEventListener("keydown", onKey);
103
114
  return () => window.removeEventListener("keydown", onKey);
104
- }, [chatOpen, onAskAiOpenChange]);
115
+ }, [chatOpen, setAskAi]);
105
116
 
106
117
  function toggleAskAi() {
107
118
  if (askAiOpen) {
108
- onAskAiOpenChange?.(false);
119
+ setAskAi(false);
109
120
  return;
110
121
  }
111
122
  if (collapsed) onRequestExpand?.();
112
- onAskAiOpenChange?.(true);
123
+ setAskAi(true);
113
124
  }
114
125
 
115
126
  const askAiButton = (
@@ -151,7 +162,7 @@ export function SuiteSidebar({
151
162
  type="button"
152
163
  data-test="suite-ask-ai-back"
153
164
  aria-label="Back to navigation"
154
- onClick={() => onAskAiOpenChange?.(false)}
165
+ onClick={() => setAskAi(false)}
155
166
  className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-ph-subtle transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
156
167
  >
157
168
  <NavArrowLeft className="h-4 w-4" aria-hidden />
@@ -213,7 +224,7 @@ export function SuiteSidebar({
213
224
  <button
214
225
  type="button"
215
226
  data-test="suite-ask-ai-back"
216
- onClick={() => onAskAiOpenChange?.(false)}
227
+ onClick={() => setAskAi(false)}
217
228
  className="inline-flex h-9 items-center gap-1 rounded-lg px-2 text-sm font-medium text-ph-subtle transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
218
229
  >
219
230
  <NavArrowLeft className="h-4 w-4" aria-hidden />
@@ -292,10 +303,7 @@ export function SuiteSidebar({
292
303
  </nav>
293
304
 
294
305
  {secondaryNav ? (
295
- <div
296
- className="min-h-0 flex-1"
297
- data-test="suite-sidebar-secondary-nav"
298
- >
306
+ <div className="shrink-0" data-test="suite-sidebar-secondary-nav">
299
307
  {secondaryNav}
300
308
  </div>
301
309
  ) : null}
@@ -53,7 +53,17 @@ export {
53
53
  SuiteAiPanel,
54
54
  openSuiteAskAi,
55
55
  consumePendingAskAiPrompt,
56
+ persistSuiteAskAiOpen,
57
+ readSuiteAskAiOpen,
58
+ pathWithSuiteAskAiOpen,
59
+ consumeSuiteAskAiOpenFromLocation,
56
60
  SUITE_OPEN_ASK_AI_EVENT,
61
+ SUITE_ASK_AI_OPEN_KEY,
62
+ SUITE_ASK_AI_OPEN_QUERY,
63
+ SUITE_MUTATED_EVENT,
64
+ TURTLETIME_TIMER_MUTATED_EVENT,
65
+ type SuiteAiAction,
66
+ type SuiteAiActionStatus,
57
67
  type SuiteAiPreset,
58
68
  type SuiteAiChatMessage,
59
69
  type SuiteAskAiOpenDetail,