sunpeak 0.16.17 → 0.16.21

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 (145) hide show
  1. package/README.md +2 -2
  2. package/bin/commands/build.mjs +1 -1
  3. package/bin/commands/dev.mjs +137 -19
  4. package/bin/commands/new.mjs +21 -2
  5. package/bin/commands/start.mjs +1 -1
  6. package/dist/chatgpt/chatgpt-conversation.d.ts +3 -1
  7. package/dist/chatgpt/globals.css +37 -8
  8. package/dist/chatgpt/index.cjs +3 -5
  9. package/dist/chatgpt/index.cjs.map +1 -1
  10. package/dist/chatgpt/index.d.ts +0 -1
  11. package/dist/chatgpt/index.js +3 -5
  12. package/dist/chatgpt/index.js.map +1 -1
  13. package/dist/claude/claude-conversation.d.ts +3 -1
  14. package/dist/claude/index.cjs +2 -2
  15. package/dist/claude/index.d.ts +1 -1
  16. package/dist/claude/index.js +2 -2
  17. package/dist/{discovery-DvIQWTez.js → discovery-BVqD-JsT.js} +4 -2
  18. package/dist/{discovery-DvIQWTez.js.map → discovery-BVqD-JsT.js.map} +1 -1
  19. package/dist/{discovery-SviNiBkF.cjs → discovery-D1gpaVz4.cjs} +4 -2
  20. package/dist/{discovery-SviNiBkF.cjs.map → discovery-D1gpaVz4.cjs.map} +1 -1
  21. package/dist/hooks/index.d.ts +10 -1
  22. package/dist/hooks/safe-area.d.ts +6 -2
  23. package/dist/hooks/use-device-capabilities.d.ts +3 -0
  24. package/dist/hooks/use-platform.d.ts +3 -0
  25. package/dist/hooks/use-styles.d.ts +2 -0
  26. package/dist/hooks/use-time-zone.d.ts +1 -0
  27. package/dist/hooks/use-tool-info.d.ts +3 -0
  28. package/dist/hooks/use-user-agent.d.ts +1 -0
  29. package/dist/hooks/use-viewport.d.ts +3 -0
  30. package/dist/{platform → host}/chatgpt/index.cjs +1 -1
  31. package/dist/host/chatgpt/index.cjs.map +1 -0
  32. package/dist/{platform → host}/chatgpt/index.d.ts +2 -2
  33. package/dist/{platform → host}/chatgpt/index.js +1 -1
  34. package/dist/host/chatgpt/index.js.map +1 -0
  35. package/dist/{platform → host}/chatgpt/use-create-file.d.ts +2 -2
  36. package/dist/{platform → host}/chatgpt/use-file-download.d.ts +2 -2
  37. package/dist/{platform → host}/chatgpt/use-open-modal.d.ts +2 -2
  38. package/dist/{platform → host}/chatgpt/use-request-checkout.d.ts +2 -2
  39. package/dist/{platform → host}/index.cjs +5 -3
  40. package/dist/host/index.cjs.map +1 -0
  41. package/dist/{platform → host}/index.d.ts +15 -11
  42. package/dist/{platform → host}/index.js +5 -3
  43. package/dist/host/index.js.map +1 -0
  44. package/dist/{index-CsYoMHyn.js → index-B4aC3vjH.js} +4 -4
  45. package/dist/index-B4aC3vjH.js.map +1 -0
  46. package/dist/{index-DHcaJ5PU.cjs → index-CKabCJyV.cjs} +4 -4
  47. package/dist/index-CKabCJyV.cjs.map +1 -0
  48. package/dist/index-CX6Z4bED.js +29 -0
  49. package/dist/index-CX6Z4bED.js.map +1 -0
  50. package/dist/index-bKBBCBK6.cjs +28 -0
  51. package/dist/index-bKBBCBK6.cjs.map +1 -0
  52. package/dist/index.cjs +233 -6297
  53. package/dist/index.cjs.map +1 -1
  54. package/dist/index.d.ts +4 -2
  55. package/dist/index.js +228 -6292
  56. package/dist/index.js.map +1 -1
  57. package/dist/lib/discovery-cli.cjs +1 -1
  58. package/dist/lib/discovery-cli.js +1 -1
  59. package/dist/mcp/index.cjs +680 -6766
  60. package/dist/mcp/index.cjs.map +1 -1
  61. package/dist/mcp/index.js +682 -6768
  62. package/dist/mcp/index.js.map +1 -1
  63. package/dist/{protocol-CfvM5B6z.cjs → protocol-DkDHRwOW.cjs} +50 -5
  64. package/dist/{protocol-CfvM5B6z.cjs.map → protocol-DkDHRwOW.cjs.map} +1 -1
  65. package/dist/{protocol-CF-P_kw5.js → protocol-uge7qFev.js} +102 -57
  66. package/dist/{protocol-CF-P_kw5.js.map → protocol-uge7qFev.js.map} +1 -1
  67. package/dist/simulator/hosts.d.ts +2 -0
  68. package/dist/simulator/index.cjs +3 -3
  69. package/dist/simulator/index.js +3 -3
  70. package/dist/simulator/simple-sidebar.d.ts +18 -4
  71. package/dist/simulator/simulator-url.d.ts +8 -0
  72. package/dist/simulator/simulator.d.ts +13 -1
  73. package/dist/simulator/use-simulator-state.d.ts +10 -6
  74. package/dist/simulator-D8t-r7HH.js +3222 -0
  75. package/dist/simulator-D8t-r7HH.js.map +1 -0
  76. package/dist/simulator-FFNttkqL.cjs +3237 -0
  77. package/dist/simulator-FFNttkqL.cjs.map +1 -0
  78. package/dist/{simulator-url-rgg_KYOg.cjs → simulator-url-DcSYRl-P.cjs} +7 -1
  79. package/dist/simulator-url-DcSYRl-P.cjs.map +1 -0
  80. package/dist/{simulator-url-CuLqtnSS.js → simulator-url-j_XV3EoP.js} +7 -1
  81. package/dist/simulator-url-j_XV3EoP.js.map +1 -0
  82. package/dist/style.css +37 -8
  83. package/dist/use-app-C9gpzIQO.js +349 -0
  84. package/dist/use-app-C9gpzIQO.js.map +1 -0
  85. package/dist/use-app-D09O2swh.cjs +348 -0
  86. package/dist/use-app-D09O2swh.cjs.map +1 -0
  87. package/package.json +26 -14
  88. package/template/.sunpeak/dev.tsx +28 -2
  89. package/template/node_modules/.bin/vite +2 -2
  90. package/template/node_modules/.bin/vitest +2 -2
  91. package/template/package.json +5 -5
  92. package/template/playwright.config.ts +6 -3
  93. package/template/src/resources/albums/albums.test.tsx +1 -0
  94. package/template/src/resources/albums/albums.tsx +5 -2
  95. package/template/src/resources/albums/components/albums.test.tsx +22 -18
  96. package/template/src/resources/albums/components/albums.tsx +63 -7
  97. package/template/src/resources/albums/components/fullscreen-viewer.test.tsx +3 -25
  98. package/template/src/resources/albums/components/fullscreen-viewer.tsx +2 -3
  99. package/template/src/resources/carousel/carousel.test.tsx +12 -16
  100. package/template/src/resources/carousel/carousel.tsx +47 -5
  101. package/template/src/resources/map/components/map.tsx +65 -9
  102. package/template/src/resources/map/map.test.tsx +0 -1
  103. package/template/src/resources/review/review.test.tsx +25 -27
  104. package/template/src/resources/review/review.tsx +85 -63
  105. package/template/src/tools/review-diff.test.ts +73 -0
  106. package/template/src/tools/review-diff.ts +29 -2
  107. package/template/src/tools/review-post.test.ts +100 -0
  108. package/template/src/tools/review-post.ts +30 -2
  109. package/template/src/tools/review-purchase.test.ts +111 -0
  110. package/template/src/tools/review-purchase.ts +35 -2
  111. package/template/src/tools/review.test.ts +40 -0
  112. package/template/src/tools/review.ts +4 -1
  113. package/template/src/tools/show-albums.test.ts +42 -0
  114. package/template/src/tools/show-albums.ts +22 -2
  115. package/template/src/tools/show-carousel.test.ts +45 -0
  116. package/template/src/tools/show-carousel.ts +19 -2
  117. package/template/src/tools/show-map.test.ts +74 -0
  118. package/template/src/tools/show-map.ts +21 -2
  119. package/template/tests/e2e/albums.spec.ts +75 -0
  120. package/template/tests/e2e/carousel.spec.ts +65 -0
  121. package/template/tests/e2e/global-setup.ts +25 -0
  122. package/template/tests/e2e/map.spec.ts +60 -0
  123. package/template/tests/e2e/review.spec.ts +72 -11
  124. package/dist/chatgpt/chatgpt-simulator.d.ts +0 -10
  125. package/dist/index-BFD3bAHd.cjs +0 -547
  126. package/dist/index-BFD3bAHd.cjs.map +0 -1
  127. package/dist/index-CsYoMHyn.js.map +0 -1
  128. package/dist/index-DHcaJ5PU.cjs.map +0 -1
  129. package/dist/index-wUvmyoCx.js +0 -532
  130. package/dist/index-wUvmyoCx.js.map +0 -1
  131. package/dist/platform/chatgpt/index.cjs.map +0 -1
  132. package/dist/platform/chatgpt/index.js.map +0 -1
  133. package/dist/platform/index.cjs.map +0 -1
  134. package/dist/platform/index.js.map +0 -1
  135. package/dist/simulator-BEFsuj9Z.cjs +0 -8872
  136. package/dist/simulator-BEFsuj9Z.cjs.map +0 -1
  137. package/dist/simulator-Da9iAupa.js +0 -8857
  138. package/dist/simulator-Da9iAupa.js.map +0 -1
  139. package/dist/simulator-url-CuLqtnSS.js.map +0 -1
  140. package/dist/simulator-url-rgg_KYOg.cjs.map +0 -1
  141. package/dist/use-app-CaTJmpgj.cjs +0 -6449
  142. package/dist/use-app-CaTJmpgj.cjs.map +0 -1
  143. package/dist/use-app-DTTzqi-0.js +0 -6450
  144. package/dist/use-app-DTTzqi-0.js.map +0 -1
  145. /package/dist/{platform → host}/chatgpt/openai-types.d.ts +0 -0
@@ -1,4 +1,4 @@
1
- import { useToolData, useHostContext, useDisplayMode, SafeArea } from 'sunpeak';
1
+ import { useToolData, useDeviceCapabilities, useDisplayMode, SafeArea } from 'sunpeak';
2
2
  import type { ResourceConfig } from 'sunpeak';
3
3
  import { Carousel, Card } from './components';
4
4
 
@@ -32,18 +32,60 @@ interface CarouselCard {
32
32
  description: string;
33
33
  }
34
34
 
35
+ interface CarouselInput {
36
+ city?: string;
37
+ state?: string;
38
+ categories?: string[];
39
+ limit?: number;
40
+ }
41
+
35
42
  interface CarouselData {
36
43
  places: CarouselCard[];
37
44
  }
38
45
 
39
46
  export function CarouselResource() {
40
- const { output } = useToolData<unknown, CarouselData>(undefined, { places: [] });
41
- const context = useHostContext();
47
+ const { output, inputPartial, isLoading, isError, isCancelled, cancelReason } = useToolData<
48
+ CarouselInput,
49
+ CarouselData
50
+ >();
51
+ const { touch: hasTouch = false } = useDeviceCapabilities();
42
52
  const displayMode = useDisplayMode();
43
-
44
- const hasTouch = context?.deviceCapabilities?.touch ?? false;
45
53
  const places = output?.places ?? [];
46
54
 
55
+ if (isLoading) {
56
+ const searchContext = inputPartial?.city;
57
+ return (
58
+ <SafeArea className="flex items-center justify-center gap-2 p-8 text-[var(--color-text-secondary)]">
59
+ <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
60
+ <span>{searchContext ? `Finding places in ${searchContext}…` : 'Loading places…'}</span>
61
+ </SafeArea>
62
+ );
63
+ }
64
+
65
+ if (isError) {
66
+ return (
67
+ <SafeArea className="flex items-center justify-center p-8 text-[var(--color-text-secondary)]">
68
+ Failed to load places
69
+ </SafeArea>
70
+ );
71
+ }
72
+
73
+ if (isCancelled) {
74
+ return (
75
+ <SafeArea className="flex items-center justify-center p-8 text-[var(--color-text-secondary)]">
76
+ {cancelReason ?? 'Request was cancelled'}
77
+ </SafeArea>
78
+ );
79
+ }
80
+
81
+ if (places.length === 0) {
82
+ return (
83
+ <SafeArea className="flex items-center justify-center p-8 text-[var(--color-text-secondary)]">
84
+ No places found
85
+ </SafeArea>
86
+ );
87
+ }
88
+
47
89
  return (
48
90
  <SafeArea className="p-4">
49
91
  <Carousel
@@ -1,5 +1,12 @@
1
1
  import * as React from 'react';
2
- import { useApp, useAppState, useDisplayMode, useToolData, useViewport } from 'sunpeak';
2
+ import {
3
+ useAppState,
4
+ useDisplayMode,
5
+ useRequestDisplayMode,
6
+ useToolData,
7
+ useViewport,
8
+ useUpdateModelContext,
9
+ } from 'sunpeak';
3
10
  import { Button } from '@/components/button';
4
11
  import { ExpandLg } from '@/components/icon';
5
12
  import { cn } from '@/lib/index';
@@ -9,6 +16,14 @@ import { PlaceInspector } from './place-inspector';
9
16
  import { MapView } from './map-view';
10
17
  import type { Place, MapData } from './types';
11
18
 
19
+ interface MapInput {
20
+ query?: string;
21
+ location?: { lat: number; lng: number };
22
+ radius?: number;
23
+ minRating?: number;
24
+ priceRange?: string[];
25
+ }
26
+
12
27
  interface MapState {
13
28
  selectedPlaceId: string | null;
14
29
  }
@@ -18,24 +33,32 @@ export type MapProps = {
18
33
  };
19
34
 
20
35
  export function Map({ className }: MapProps) {
21
- const app = useApp();
22
- const { output } = useToolData<unknown, MapData>(undefined, { places: [] });
36
+ const { output, inputPartial, isLoading, isError, isCancelled, cancelReason } = useToolData<
37
+ MapInput,
38
+ MapData
39
+ >();
23
40
  const [state, setState] = useAppState<MapState>({
24
41
  selectedPlaceId: null,
25
42
  });
26
43
  const displayMode = useDisplayMode();
44
+ const { requestDisplayMode, availableModes } = useRequestDisplayMode();
27
45
  const viewport = useViewport();
46
+ const updateModelContext = useUpdateModelContext();
28
47
 
29
48
  const maxHeight = viewport?.maxHeight ?? null;
30
49
  const places = output?.places ?? [];
31
50
  const selectedPlace = places.find((place: Place) => place.id === state.selectedPlaceId);
32
51
  const isFullscreen = displayMode === 'fullscreen';
52
+ const canFullscreen = availableModes?.includes('fullscreen') ?? false;
33
53
 
34
54
  const handleSelectPlace = React.useCallback(
35
55
  (place: Place) => {
36
56
  setState((prev) => ({ ...prev, selectedPlaceId: place.id }));
57
+ updateModelContext({
58
+ structuredContent: { selectedPlace: { id: place.id, name: place.name } },
59
+ });
37
60
  },
38
- [setState]
61
+ [setState, updateModelContext]
39
62
  );
40
63
 
41
64
  const handleCloseInspector = React.useCallback(() => {
@@ -47,8 +70,42 @@ export function Map({ className }: MapProps) {
47
70
  if (state.selectedPlaceId) {
48
71
  setState((prev) => ({ ...prev, selectedPlaceId: null }));
49
72
  }
50
- app?.requestDisplayMode({ mode: 'fullscreen' });
51
- }, [app, state.selectedPlaceId, setState]);
73
+ requestDisplayMode('fullscreen');
74
+ }, [requestDisplayMode, state.selectedPlaceId, setState]);
75
+
76
+ if (isLoading) {
77
+ const searchContext = inputPartial?.query;
78
+ return (
79
+ <div className="flex items-center justify-center gap-2 p-8 text-[var(--color-text-secondary)]">
80
+ <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
81
+ <span>{searchContext ? `Searching for ${searchContext}…` : 'Loading map…'}</span>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ if (isError) {
87
+ return (
88
+ <div className="flex items-center justify-center p-8 text-[var(--color-text-secondary)]">
89
+ Failed to load map data
90
+ </div>
91
+ );
92
+ }
93
+
94
+ if (isCancelled) {
95
+ return (
96
+ <div className="flex items-center justify-center p-8 text-[var(--color-text-secondary)]">
97
+ {cancelReason ?? 'Request was cancelled'}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ if (places.length === 0) {
103
+ return (
104
+ <div className="flex items-center justify-center p-8 text-[var(--color-text-secondary)]">
105
+ No places found
106
+ </div>
107
+ );
108
+ }
52
109
 
53
110
  const containerHeight = isFullscreen ? (maxHeight ?? 600) - 40 : 480;
54
111
 
@@ -58,7 +115,6 @@ export function Map({ className }: MapProps) {
58
115
  style={{
59
116
  height: containerHeight,
60
117
  minHeight: 480,
61
- maxHeight: maxHeight ?? undefined,
62
118
  }}
63
119
  >
64
120
  <div
@@ -69,8 +125,8 @@ export function Map({ className }: MapProps) {
69
125
  : 'border border-[var(--color-border-tertiary)] rounded-2xl sm:rounded-3xl'
70
126
  )}
71
127
  >
72
- {/* Fullscreen button - only show in embedded mode */}
73
- {!isFullscreen && (
128
+ {/* Fullscreen button - only show when fullscreen is available and not already fullscreen */}
129
+ {!isFullscreen && canFullscreen && (
74
130
  <Button
75
131
  variant="solid"
76
132
  color="secondary"
@@ -4,7 +4,6 @@ import { MapResource } from './map';
4
4
 
5
5
  // Mock sunpeak — SafeArea renders as a plain div
6
6
  vi.mock('sunpeak', () => ({
7
- useApp: () => null,
8
7
  SafeArea: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => (
9
8
  <div data-testid="safe-area" {...props}>
10
9
  {children}
@@ -15,21 +15,18 @@ let mockState: Record<string, unknown> = {
15
15
  serverMessage: null,
16
16
  serverError: false,
17
17
  };
18
- let mockHostContext: {
19
- deviceCapabilities?: { hover: boolean; touch: boolean };
20
- } | null = {
21
- deviceCapabilities: { hover: true, touch: false },
18
+ let mockDeviceCapabilities: { hover?: boolean; touch?: boolean } = {
19
+ hover: true,
20
+ touch: false,
22
21
  };
22
+ let mockAvailableModes: string[] = ['inline', 'fullscreen'];
23
23
  let mockDisplayMode: 'inline' | 'fullscreen' = 'inline';
24
24
 
25
- const mockApp = {
26
- requestDisplayMode: mockRequestDisplayMode,
27
- };
25
+ const mockUpdateModelContext = vi.fn();
28
26
 
29
27
  vi.mock('sunpeak', () => ({
30
- useApp: () => mockApp,
31
- useToolData: (_defaultInput: unknown, defaultOutput: Record<string, unknown>) => ({
32
- output: { ...defaultOutput, ...mockToolOutput },
28
+ useToolData: () => ({
29
+ output: mockToolOutput,
33
30
  input: null,
34
31
  inputPartial: null,
35
32
  isError: false,
@@ -37,10 +34,18 @@ vi.mock('sunpeak', () => ({
37
34
  isCancelled: false,
38
35
  cancelReason: null,
39
36
  }),
40
- useHostContext: () => mockHostContext,
37
+ useDeviceCapabilities: () => mockDeviceCapabilities,
38
+ useHostInfo: () => ({ hostVersion: undefined, hostCapabilities: { serverTools: true } }),
41
39
  useDisplayMode: () => mockDisplayMode,
40
+ useRequestDisplayMode: () => ({
41
+ requestDisplayMode: mockRequestDisplayMode,
42
+ availableModes: mockAvailableModes,
43
+ }),
42
44
  useAppState: () => [mockState, mockSetState],
43
45
  useCallServerTool: () => mockCallServerTool,
46
+ useUpdateModelContext: () => mockUpdateModelContext,
47
+ useTimeZone: () => 'America/New_York',
48
+ useLocale: () => 'en-US',
44
49
  SafeArea: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => (
45
50
  <div data-testid="safe-area" {...props}>
46
51
  {children}
@@ -100,7 +105,8 @@ describe('ReviewResource', () => {
100
105
  serverMessage: null,
101
106
  serverError: false,
102
107
  };
103
- mockHostContext = { deviceCapabilities: { hover: true, touch: false } };
108
+ mockDeviceCapabilities = { hover: true, touch: false };
109
+ mockAvailableModes = ['inline', 'fullscreen'];
104
110
  mockDisplayMode = 'inline';
105
111
  });
106
112
 
@@ -123,14 +129,6 @@ describe('ReviewResource', () => {
123
129
 
124
130
  expect(screen.getByText('Please review the following items')).toBeInTheDocument();
125
131
  });
126
-
127
- it('renders loading when no sections', () => {
128
- mockToolOutput = { title: 'Test', sections: [] };
129
-
130
- render(<ReviewResource />);
131
-
132
- expect(screen.getByText('Loading...')).toBeInTheDocument();
133
- });
134
132
  });
135
133
 
136
134
  describe('Action Buttons', () => {
@@ -610,8 +608,8 @@ describe('ReviewResource', () => {
610
608
  expect(safeArea).toBeInTheDocument();
611
609
  });
612
610
 
613
- it('handles null host context', () => {
614
- mockHostContext = null;
611
+ it('handles empty device capabilities gracefully', () => {
612
+ mockDeviceCapabilities = {};
615
613
 
616
614
  // Should render without errors
617
615
  render(<ReviewResource />);
@@ -621,7 +619,7 @@ describe('ReviewResource', () => {
621
619
 
622
620
  describe('Touch Device Support', () => {
623
621
  it('renders larger buttons for touch devices', () => {
624
- mockHostContext = { deviceCapabilities: { hover: false, touch: true } };
622
+ mockDeviceCapabilities = { hover: false, touch: true };
625
623
 
626
624
  render(<ReviewResource />);
627
625
 
@@ -633,7 +631,7 @@ describe('ReviewResource', () => {
633
631
  });
634
632
 
635
633
  it('renders standard buttons for non-touch devices', () => {
636
- mockHostContext = { deviceCapabilities: { hover: true, touch: false } };
634
+ mockDeviceCapabilities = { hover: true, touch: false };
637
635
 
638
636
  render(<ReviewResource />);
639
637
 
@@ -644,8 +642,8 @@ describe('ReviewResource', () => {
644
642
  expect(rejectButton).toHaveAttribute('data-size', 'md');
645
643
  });
646
644
 
647
- it('handles null host context gracefully', () => {
648
- mockHostContext = null;
645
+ it('handles empty device capabilities gracefully', () => {
646
+ mockDeviceCapabilities = {};
649
647
 
650
648
  render(<ReviewResource />);
651
649
 
@@ -679,7 +677,7 @@ describe('ReviewResource', () => {
679
677
  const expandButton = screen.getByLabelText('Enter fullscreen');
680
678
  fireEvent.click(expandButton);
681
679
 
682
- expect(mockRequestDisplayMode).toHaveBeenCalledWith({ mode: 'fullscreen' });
680
+ expect(mockRequestDisplayMode).toHaveBeenCalledWith('fullscreen');
683
681
  });
684
682
  });
685
683
  });
@@ -1,10 +1,14 @@
1
1
  import {
2
- useApp,
3
2
  useAppState,
4
3
  useToolData,
5
- useHostContext,
4
+ useDeviceCapabilities,
5
+ useHostInfo,
6
6
  useDisplayMode,
7
+ useRequestDisplayMode,
7
8
  useCallServerTool,
9
+ useUpdateModelContext,
10
+ useTimeZone,
11
+ useLocale,
8
12
  SafeArea,
9
13
  } from 'sunpeak';
10
14
  import type { ResourceConfig } from 'sunpeak';
@@ -494,12 +498,10 @@ function AlertBanner({ alert }: { alert: Alert }) {
494
498
  // ============================================================================
495
499
 
496
500
  export function ReviewResource() {
497
- const app = useApp();
498
-
499
- const { output } = useToolData<unknown, ReviewData>(undefined, {
500
- title: 'Review',
501
- sections: [],
502
- });
501
+ const { output, isLoading, isError, isCancelled, cancelReason } = useToolData<
502
+ unknown,
503
+ ReviewData
504
+ >();
503
505
 
504
506
  const [state, setState] = useAppState<ReviewState>({
505
507
  decision: null,
@@ -509,27 +511,37 @@ export function ReviewResource() {
509
511
  serverError: false,
510
512
  });
511
513
 
512
- const context = useHostContext();
514
+ const { touch: hasTouch = false } = useDeviceCapabilities();
515
+ const { hostCapabilities } = useHostInfo();
513
516
  const displayMode = useDisplayMode();
517
+ const { requestDisplayMode, availableModes } = useRequestDisplayMode();
518
+ const callServerTool = useCallServerTool();
519
+ const updateModelContext = useUpdateModelContext();
520
+ const timeZone = useTimeZone();
521
+ const locale = useLocale();
514
522
 
515
- const hasTouch = context?.deviceCapabilities?.touch ?? false;
523
+ const canFullscreen = availableModes?.includes('fullscreen') ?? false;
524
+ const hasServerTools = !!hostCapabilities?.serverTools;
516
525
  const decision = state.decision ?? null;
517
526
  const isFullscreen = displayMode === 'fullscreen';
518
527
  const data = output ?? { title: 'Review', sections: [] as Section[] };
519
528
 
520
529
  const handleRequestFullscreen = () => {
521
- app?.requestDisplayMode({ mode: 'fullscreen' });
530
+ requestDisplayMode('fullscreen');
522
531
  };
523
532
 
524
- const callServerTool = useCallServerTool();
525
-
526
533
  const handleDecision = async (confirmed: boolean) => {
527
534
  const decidedAt = new Date().toISOString();
528
535
  const decision = confirmed ? 'accepted' : 'rejected';
529
536
 
537
+ // Inform the model about the user's decision
538
+ updateModelContext({
539
+ structuredContent: { decision, title: data.title, decidedAt },
540
+ });
541
+
530
542
  const tool = data.reviewTool;
531
- if (!tool) {
532
- // No server tool — show result immediately
543
+ if (!tool || !hasServerTools) {
544
+ // No server tool or host doesn't support server tools — show result immediately
533
545
  setState({ decision, decidedAt, pending: false, serverMessage: null, serverError: false });
534
546
  return;
535
547
  }
@@ -568,58 +580,71 @@ export function ReviewResource() {
568
580
  const sections = data.sections ?? [];
569
581
  const alerts = data.alerts ?? [];
570
582
 
583
+ if (isLoading) {
584
+ return (
585
+ <SafeArea className="flex items-center justify-center gap-2 p-8 text-[var(--color-text-secondary)]">
586
+ <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
587
+ <span>Loading…</span>
588
+ </SafeArea>
589
+ );
590
+ }
591
+
592
+ if (isError) {
593
+ return (
594
+ <SafeArea className="flex items-center justify-center p-8 text-[var(--color-text-secondary)]">
595
+ Failed to load review data
596
+ </SafeArea>
597
+ );
598
+ }
599
+
600
+ if (isCancelled) {
601
+ return (
602
+ <SafeArea className="flex items-center justify-center p-8 text-[var(--color-text-secondary)]">
603
+ {cancelReason ?? 'Request was cancelled'}
604
+ </SafeArea>
605
+ );
606
+ }
607
+
571
608
  return (
572
- <SafeArea className="flex flex-col">
609
+ <SafeArea className="px-4 py-4 space-y-4">
573
610
  {/* Header */}
574
- <div className="px-4 pt-4 pb-3 border-b border-[var(--color-border-tertiary)]">
575
- <div className="flex items-start justify-between gap-2">
576
- <div className="flex-1 min-w-0">
577
- <h1 className="text-xl font-semibold text-[var(--color-text-primary)]">{data.title}</h1>
578
- {data.description && (
579
- <p className="mt-1 text-sm text-[var(--color-text-secondary)]">{data.description}</p>
580
- )}
581
- </div>
582
- {!isFullscreen && (
583
- <Button
584
- variant="ghost"
585
- color="secondary"
586
- size="sm"
587
- onClick={handleRequestFullscreen}
588
- aria-label="Enter fullscreen"
589
- className="flex-shrink-0"
590
- >
591
- <ExpandLg className="h-4 w-4" aria-hidden="true" />
592
- </Button>
611
+ <div className="flex items-start justify-between gap-2">
612
+ <div className="flex-1 min-w-0">
613
+ <h1 className="text-xl font-semibold text-[var(--color-text-primary)]">{data.title}</h1>
614
+ {data.description && (
615
+ <p className="mt-1 text-sm text-[var(--color-text-secondary)]">{data.description}</p>
593
616
  )}
594
617
  </div>
618
+ {!isFullscreen && canFullscreen && (
619
+ <Button
620
+ variant="ghost"
621
+ color="secondary"
622
+ size="sm"
623
+ onClick={handleRequestFullscreen}
624
+ aria-label="Enter fullscreen"
625
+ className="flex-shrink-0"
626
+ >
627
+ <ExpandLg className="h-4 w-4" aria-hidden="true" />
628
+ </Button>
629
+ )}
595
630
  </div>
596
631
 
597
- {/* Content */}
598
- <div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
599
- {/* Alerts */}
600
- {alerts.length > 0 && (
601
- <div className="space-y-2">
602
- {alerts.map((alert, i) => (
603
- <AlertBanner key={i} alert={alert} />
604
- ))}
605
- </div>
606
- )}
632
+ {/* Alerts */}
633
+ {alerts.length > 0 && (
634
+ <div className="space-y-2">
635
+ {alerts.map((alert, i) => (
636
+ <AlertBanner key={i} alert={alert} />
637
+ ))}
638
+ </div>
639
+ )}
607
640
 
608
- {/* Sections */}
609
- {sections.length === 0 ? (
610
- // Note: Apps cannot distinguish between "still loading" and "empty response".
611
- // We show a loading state as the optimistic assumption.
612
- <div className="flex items-center justify-center gap-2 py-8 text-[var(--color-text-secondary)]">
613
- <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
614
- <span>Loading...</span>
615
- </div>
616
- ) : (
617
- sections.map((section, i) => <SectionRenderer key={i} section={section} />)
618
- )}
619
- </div>
641
+ {/* Sections */}
642
+ {sections.map((section, i) => (
643
+ <SectionRenderer key={i} section={section} />
644
+ ))}
620
645
 
621
- {/* Footer with Actions */}
622
- <div className="px-4 py-3 border-t border-[var(--color-border-tertiary)]">
646
+ {/* Actions */}
647
+ <div className="pt-2">
623
648
  {decision === null ? (
624
649
  <div className="flex gap-3">
625
650
  <Button
@@ -652,11 +677,9 @@ export function ReviewResource() {
652
677
  <div className="flex flex-col items-center gap-1">
653
678
  {state.serverMessage ? (
654
679
  <>
655
- {/* What the user clicked */}
656
680
  <span className="text-xs text-[var(--color-text-secondary)]">
657
681
  {decision === 'accepted' ? acceptedMessage : rejectedMessage}
658
682
  </span>
659
- {/* Server's result — color based on structuredContent.status */}
660
683
  <div
661
684
  className="flex items-center justify-center gap-2"
662
685
  style={{
@@ -670,7 +693,6 @@ export function ReviewResource() {
670
693
  </div>
671
694
  </>
672
695
  ) : (
673
- /* No server message (no reviewTool) — icon based on decision */
674
696
  <div
675
697
  className="flex items-center justify-center gap-2"
676
698
  style={{
@@ -688,7 +710,7 @@ export function ReviewResource() {
688
710
  )}
689
711
  {state.decidedAt && (
690
712
  <span className="text-xs text-[var(--color-text-secondary)]">
691
- {new Date(state.decidedAt).toLocaleString()}
713
+ {new Date(state.decidedAt).toLocaleString(locale, { timeZone })}
692
714
  </span>
693
715
  )}
694
716
  </div>
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import handler, { tool, schema } from './review-diff';
3
+
4
+ const extra = {} as Parameters<typeof handler>[1];
5
+
6
+ describe('review-diff tool', () => {
7
+ it('exports correct tool config', () => {
8
+ expect(tool.resource).toBe('review');
9
+ expect(tool.title).toBe('Diff Review');
10
+ expect(tool.annotations?.readOnlyHint).toBe(false);
11
+ });
12
+
13
+ it('has expected schema fields', () => {
14
+ expect(schema.changesetId).toBeDefined();
15
+ expect(schema.title).toBeDefined();
16
+ expect(schema.description).toBeDefined();
17
+ expect(schema.files).toBeDefined();
18
+ expect(schema.runMigrations).toBeDefined();
19
+ });
20
+
21
+ it('returns structured content with default values', async () => {
22
+ const result = await handler(
23
+ { changesetId: '', title: '', description: '', files: [], runMigrations: false },
24
+ extra
25
+ );
26
+ expect(result.structuredContent.title).toBe('Code Review');
27
+ expect(result.structuredContent.description).toBe('Review the proposed changes below');
28
+ expect(result.structuredContent.reviewTool).toBeDefined();
29
+ });
30
+
31
+ it('creates changes from provided files', async () => {
32
+ const result = await handler(
33
+ {
34
+ changesetId: 'cs-42',
35
+ title: 'Fix Bug',
36
+ description: 'Fixes the login bug',
37
+ files: ['src/auth.ts', 'src/login.tsx'],
38
+ runMigrations: false,
39
+ },
40
+ extra
41
+ );
42
+ expect(result.structuredContent.title).toBe('Fix Bug');
43
+ const changes = result.structuredContent.sections[0].content;
44
+ expect(changes).toHaveLength(2);
45
+ expect(changes[0].path).toBe('src/auth.ts');
46
+ expect(changes[1].path).toBe('src/login.tsx');
47
+ });
48
+
49
+ it('adds migration action when runMigrations is true', async () => {
50
+ const result = await handler(
51
+ {
52
+ changesetId: 'cs-1',
53
+ title: '',
54
+ description: '',
55
+ files: ['src/app.ts'],
56
+ runMigrations: true,
57
+ },
58
+ extra
59
+ );
60
+ const changes = result.structuredContent.sections[0].content;
61
+ expect(changes).toHaveLength(2);
62
+ expect(changes[1].type).toBe('action');
63
+ expect(changes[1].description).toContain('migrations');
64
+ });
65
+
66
+ it('passes changesetId to reviewTool arguments', async () => {
67
+ const result = await handler(
68
+ { changesetId: 'cs-99', title: '', description: '', files: [], runMigrations: false },
69
+ extra
70
+ );
71
+ expect(result.structuredContent.reviewTool.arguments.changesetId).toBe('cs-99');
72
+ });
73
+ });
@@ -21,6 +21,33 @@ export const schema = {
21
21
 
22
22
  type Args = z.infer<z.ZodObject<typeof schema>>;
23
23
 
24
- export default async function (_args: Args, _extra: ToolHandlerExtra) {
25
- return { structuredContent: { title: 'Review', sections: [] } };
24
+ export default async function (args: Args, _extra: ToolHandlerExtra) {
25
+ const files = args.files ?? ['src/app.tsx', 'src/utils/helpers.ts'];
26
+ const changes: Array<{ id: string; type: string; path?: string; description: string }> =
27
+ files.map((file, i) => ({
28
+ id: String(i + 1),
29
+ type: 'modify',
30
+ path: file,
31
+ description: `Changes to ${file.split('/').pop()}`,
32
+ }));
33
+
34
+ if (args.runMigrations) {
35
+ changes.push({
36
+ id: 'migration',
37
+ type: 'action',
38
+ description: 'Run database migrations',
39
+ });
40
+ }
41
+
42
+ return {
43
+ structuredContent: {
44
+ title: args.title || 'Code Review',
45
+ description: args.description || 'Review the proposed changes below',
46
+ sections: [{ type: 'changes', title: 'File Changes', content: changes }],
47
+ reviewTool: {
48
+ name: 'review',
49
+ arguments: { action: 'apply_changes', changesetId: args.changesetId || 'cs-1' },
50
+ },
51
+ },
52
+ };
26
53
  }