Skip to content

Commit f159f0f

Browse files
committed
리액티브 위젯 값 바인딩 프론트 배선 — 슬라이더 드래그→의존 셀 자동 갱신
HTTP POST /api/kernel/{id}/set-ui-value 엔드포인트 추가(프론트 reactive가 HTTP라 일치) — 위젯 값 갱신 후 includeSource=False로 dependents만 재실행. widgetHost의 값 위젯(elementId 보유)이 onChange에 150ms 디바운스로 setUiValue를 송신(WidgetSessionProvider의 onUiValueChange 컨텍스트 경유), 노트북 런타임(setNotebookUiValue)이 결과를 의존 셀 출력에 적용. 콜백 없이 변수 참조만으로 리액티브 바인딩. HTTP/WS 양쪽 라운드트립 테스트(executionOrder=[c], 위젯 셀 제외, 50*2=100). quality-cycle 22/22 green.
1 parent a8666d2 commit f159f0f

9 files changed

Lines changed: 194 additions & 19 deletions

File tree

editor/src/App.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ function App() {
186186
runningBlockId,
187187
sessionId,
188188
setSessionId,
189+
setUiValue,
189190
variables,
190191
} = useNotebookRuntimeState({
191192
apiOnline,
@@ -312,7 +313,10 @@ function App() {
312313

313314
return (
314315
<LocaleProvider value={localeState}>
315-
<WidgetSessionProvider sessionId={sessionId}>
316+
<WidgetSessionProvider
317+
sessionId={sessionId}
318+
onUiValueChange={({ blockId, elementId, value }) => setUiValue(blockId ?? "", elementId, value)}
319+
>
316320
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
317321
<ProductSidebar
318322
categories={filteredCategories}

editor/src/components/widgets/widgetHost.tsx

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, type CSSProperties, type ReactNode } from "react";
1+
import { useRef, useState, type CSSProperties, type ReactNode } from "react";
22

33
import { Badge } from "@/components/ui/badge";
44
import { Button } from "@/components/ui/button";
@@ -10,7 +10,7 @@ import { Textarea } from "@/components/ui/textarea";
1010
import { getCustomComponent } from "@/components/widgets/customComponentRegistry";
1111
import { cn } from "@/lib/utils";
1212
import { dispatchWidgetUiEvent } from "@/lib/widgetUiEvents";
13-
import { useWidgetSession } from "@/lib/widgetSession";
13+
import { useWidgetSession, useWidgetUiValueChange } from "@/lib/widgetSession";
1414

1515
export type WidgetEventBindings = Record<string, string>;
1616

@@ -53,7 +53,9 @@ export function WidgetHost({
5353
descriptor: WidgetDescriptor;
5454
}) {
5555
const contextSessionId = useWidgetSession();
56+
const onUiValueChange = useWidgetUiValueChange();
5657
const resolvedSessionId = sessionId ?? contextSessionId;
58+
const uiValueTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
5759
const dispatchEvent = async (callbackId: string | undefined, eventType: string, payload: unknown) => {
5860
if (!callbackId || !resolvedSessionId) return;
5961
try {
@@ -69,25 +71,38 @@ export function WidgetHost({
6971
}
7072
};
7173

72-
return <WidgetNode descriptor={descriptor} dispatchEvent={dispatchEvent} />;
74+
// 값 위젯(elementId 보유) 변경 → 그 변수를 쓰는 셀만 리액티브 갱신. 슬라이더 드래그
75+
// hammering을 막기 위해 150ms 디바운스(마지막 값만 보낸다).
76+
const dispatchUiValue = (elementId: string, value: unknown) => {
77+
if (!onUiValueChange || !elementId) return;
78+
if (uiValueTimer.current) clearTimeout(uiValueTimer.current);
79+
uiValueTimer.current = setTimeout(() => {
80+
void onUiValueChange({ blockId: blockId ?? null, elementId, value });
81+
}, 150);
82+
};
83+
84+
return <WidgetNode descriptor={descriptor} dispatchEvent={dispatchEvent} dispatchUiValue={dispatchUiValue} />;
7385
}
7486

7587
type Dispatch = (callbackId: string | undefined, eventType: string, payload: unknown) => Promise<void>;
88+
type DispatchUiValue = (elementId: string, value: unknown) => void;
7689

7790
function WidgetNode({
7891
descriptor,
7992
dispatchEvent,
93+
dispatchUiValue,
8094
}: {
8195
descriptor: WidgetDescriptor;
8296
dispatchEvent: Dispatch;
97+
dispatchUiValue: DispatchUiValue;
8398
}) {
8499
if (descriptor.type === "ui") {
85-
return <UiWidget descriptor={descriptor} dispatchEvent={dispatchEvent} />;
100+
return <UiWidget descriptor={descriptor} dispatchEvent={dispatchEvent} dispatchUiValue={dispatchUiValue} />;
86101
}
87102
if (descriptor.type === "custom") {
88103
return <CustomWidget descriptor={descriptor} />;
89104
}
90-
return <ContainerWidget descriptor={descriptor} dispatchEvent={dispatchEvent} />;
105+
return <ContainerWidget descriptor={descriptor} dispatchEvent={dispatchEvent} dispatchUiValue={dispatchUiValue} />;
91106
}
92107

93108
function CustomWidget({ descriptor }: { descriptor: WidgetDescriptor }) {
@@ -115,9 +130,11 @@ function CustomWidget({ descriptor }: { descriptor: WidgetDescriptor }) {
115130
function ContainerWidget({
116131
descriptor,
117132
dispatchEvent,
133+
dispatchUiValue,
118134
}: {
119135
descriptor: WidgetDescriptor;
120136
dispatchEvent: Dispatch;
137+
dispatchUiValue: DispatchUiValue;
121138
}) {
122139
const renderChild = (child: unknown, key: number): ReactNode => {
123140
if (isWidgetDescriptor(child)) {
@@ -126,6 +143,7 @@ function ContainerWidget({
126143
key={key}
127144
descriptor={child}
128145
dispatchEvent={dispatchEvent}
146+
dispatchUiValue={dispatchUiValue}
129147
/>
130148
);
131149
}
@@ -196,7 +214,7 @@ function ContainerWidget({
196214
>
197215
{title ? <div className="mb-1 text-xs font-semibold uppercase">{title}</div> : null}
198216
{isWidgetDescriptor(content) ? (
199-
<WidgetNode descriptor={content} dispatchEvent={dispatchEvent} />
217+
<WidgetNode descriptor={content} dispatchEvent={dispatchEvent} dispatchUiValue={dispatchUiValue} />
200218
) : (
201219
<div>{String(content ?? "")}</div>
202220
)}
@@ -215,7 +233,7 @@ function ContainerWidget({
215233
<summary className="cursor-pointer px-3 py-2 text-sm font-medium">{entry.label}</summary>
216234
<div className="px-3 pb-3">
217235
{isWidgetDescriptor(entry.content) ? (
218-
<WidgetNode descriptor={entry.content} dispatchEvent={dispatchEvent} />
236+
<WidgetNode descriptor={entry.content} dispatchEvent={dispatchEvent} dispatchUiValue={dispatchUiValue} />
219237
) : (
220238
<div className="text-sm">{String(entry.content ?? "")}</div>
221239
)}
@@ -243,7 +261,7 @@ function ContainerWidget({
243261
{items.map((entry) => (
244262
<TabsContent key={entry.label} value={entry.label}>
245263
{isWidgetDescriptor(entry.content) ? (
246-
<WidgetNode descriptor={entry.content} dispatchEvent={dispatchEvent} />
264+
<WidgetNode descriptor={entry.content} dispatchEvent={dispatchEvent} dispatchUiValue={dispatchUiValue} />
247265
) : (
248266
<div className="text-sm">{String(entry.content ?? "")}</div>
249267
)}
@@ -261,11 +279,11 @@ function ContainerWidget({
261279
data-widget="sidebar"
262280
style={{ gridTemplateColumns: String((descriptor as { width?: string }).width ?? "minmax(0,1fr)") }}
263281
>
264-
{isWidgetDescriptor(content) ? <WidgetNode descriptor={content} dispatchEvent={dispatchEvent} /> : null}
282+
{isWidgetDescriptor(content) ? <WidgetNode descriptor={content} dispatchEvent={dispatchEvent} dispatchUiValue={dispatchUiValue} /> : null}
265283
{footer ? (
266284
<div className="border-t pt-2 text-xs text-muted-foreground">
267285
{isWidgetDescriptor(footer) ? (
268-
<WidgetNode descriptor={footer} dispatchEvent={dispatchEvent} />
286+
<WidgetNode descriptor={footer} dispatchEvent={dispatchEvent} dispatchUiValue={dispatchUiValue} />
269287
) : (
270288
<div>{String(footer ?? "")}</div>
271289
)}
@@ -307,13 +325,22 @@ function ContainerWidget({
307325
function UiWidget({
308326
descriptor,
309327
dispatchEvent,
328+
dispatchUiValue,
310329
}: {
311330
descriptor: WidgetDescriptor;
312331
dispatchEvent: Dispatch;
332+
dispatchUiValue: DispatchUiValue;
313333
}) {
314334
const component = descriptor.component ?? "";
315335
const events = (descriptor.events ?? {}) as WidgetEventBindings;
316336
const label = String((descriptor as { label?: string }).label ?? "");
337+
const elementId = String((descriptor as { elementId?: unknown }).elementId ?? "");
338+
339+
// 값 변경 = 옵션 콜백(기존) + 리액티브 값-바인딩(elementId 있을 때).
340+
const emitChange = (value: unknown) => {
341+
void dispatchEvent(events.change, "change", value);
342+
if (elementId) dispatchUiValue(elementId, value);
343+
};
317344

318345
switch (component) {
319346
case "button": {
@@ -363,7 +390,7 @@ function UiWidget({
363390
min={numberOrUndefined((descriptor as { min?: unknown }).min)}
364391
max={numberOrUndefined((descriptor as { max?: unknown }).max)}
365392
step={numberOrUndefined((descriptor as { step?: unknown }).step)}
366-
onChange={(event) => dispatchEvent(events.change, "change", Number(event.target.value))}
393+
onChange={(event) => emitChange(Number(event.target.value))}
367394
/>
368395
</UiInputWrapper>
369396
);
@@ -378,7 +405,7 @@ function UiWidget({
378405
min={Number((descriptor as { min?: unknown }).min ?? 0)}
379406
max={Number((descriptor as { max?: unknown }).max ?? 100)}
380407
step={Number((descriptor as { step?: unknown }).step ?? 1)}
381-
onChange={(event) => dispatchEvent(events.change, "change", Number(event.target.value))}
408+
onChange={(event) => emitChange(Number(event.target.value))}
382409
/>
383410
</UiInputWrapper>
384411
);
@@ -389,7 +416,7 @@ function UiWidget({
389416
<input
390417
type="checkbox"
391418
defaultChecked={Boolean((descriptor as { value?: unknown }).value)}
392-
onChange={(event) => dispatchEvent(events.change, "change", event.target.checked)}
419+
onChange={(event) => emitChange(event.target.checked)}
393420
/>
394421
<span>{label}</span>
395422
</label>
@@ -403,7 +430,7 @@ function UiWidget({
403430
data-widget-ui="dropdown"
404431
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
405432
defaultValue={value}
406-
onChange={(event) => dispatchEvent(events.change, "change", event.target.value)}
433+
onChange={(event) => emitChange(event.target.value)}
407434
>
408435
{options.map((option) => (
409436
<option key={option} value={option}>

editor/src/hooks/useNotebookRuntimeState.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
resolveBlockRunCode,
77
runNotebookBlock,
88
runReactiveNotebook,
9+
setNotebookUiValue,
910
} from "@/lib/notebookRuntime";
1011
import type { SurfaceMode } from "@/lib/surfaceModel";
1112
import type {
@@ -109,6 +110,22 @@ export function useNotebookRuntimeState({
109110
}
110111
}, [apiOnline, codeBlocks, document, drafts, onNotice, selectedBlock, sessionId, variables]);
111112

113+
const setUiValue = useCallback(async (blockId: string, elementId: string, value: unknown) => {
114+
if (!sessionId) return;
115+
// 위젯 값 변경 → 그 변수를 쓰는 다운스트림 셀 출력만 갱신(위젯 정의 셀은 재실행 안 함).
116+
const outcome = await setNotebookUiValue({
117+
sessionId,
118+
document,
119+
drafts,
120+
blockId,
121+
elementId,
122+
value,
123+
previousVariables: variables,
124+
});
125+
if (outcome.results) setResults((current) => ({ ...current, ...outcome.results }));
126+
if (outcome.variables) setVariables(outcome.variables);
127+
}, [sessionId, document, drafts, variables]);
128+
112129
useEffect(() => {
113130
if (typeof window === "undefined") return;
114131
const handler = (event: Event) => {
@@ -137,6 +154,7 @@ export function useNotebookRuntimeState({
137154
runningBlockId,
138155
sessionId,
139156
setSessionId,
157+
setUiValue,
140158
variables,
141159
};
142160
}

editor/src/lib/api.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,22 @@ export const codaroApi = {
216216
sessionId: string,
217217
blockId: string,
218218
blocks: Array<{ id: string; type: "code" | "markdown"; content: string }>,
219-
) => postJson<{ results: ExecutionResult[]; executionOrder: string[] }>(
219+
) => postJson<{ results: ExecutionResult[]; executionOrder: string[]; cycles?: string[][] }>(
220220
`/api/kernel/${sessionId}/execute-reactive`,
221221
{ blockId, blocks },
222222
),
223+
setUiValue: (
224+
sessionId: string,
225+
payload: {
226+
blockId: string;
227+
elementId: string;
228+
value: unknown;
229+
blocks: Array<{ id: string; type: "code" | "markdown"; content: string }>;
230+
},
231+
) => postJson<{ results: ExecutionResult[]; executionOrder: string[]; cycles?: string[][] }>(
232+
`/api/kernel/${sessionId}/set-ui-value`,
233+
payload,
234+
),
223235
variables: (sessionId: string) => requestJson<VariableInfo[]>(`/api/kernel/${sessionId}/variables`),
224236
resetSession: (sessionId: string) => postJson<{ status: string }>(`/api/kernel/${sessionId}/reset`, {}),
225237
complete: (payload: {

editor/src/lib/notebookRuntime.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,51 @@ export async function runReactiveNotebook({
215215
}
216216
}
217217

218+
export async function setNotebookUiValue({
219+
sessionId,
220+
document,
221+
drafts,
222+
blockId,
223+
elementId,
224+
value,
225+
previousVariables,
226+
}: {
227+
sessionId: string | null;
228+
document: CodaroDocument;
229+
drafts: Record<string, string>;
230+
blockId: string;
231+
elementId: string;
232+
value: unknown;
233+
previousVariables: VariableInfo[];
234+
}): Promise<{ sessionId: string | null; results?: ResultMap; variables?: VariableInfo[] }> {
235+
if (!sessionId) return { sessionId };
236+
try {
237+
const payload = await codaroApi.setUiValue(sessionId, {
238+
blockId,
239+
elementId,
240+
value,
241+
blocks: document.blocks
242+
.filter((block) => isExecutableBlock(block) || block.type === "markdown")
243+
.map((block) => ({
244+
id: block.id,
245+
type: isExecutableBlock(block) ? "code" : ("markdown" as const),
246+
content: drafts[block.id] ?? block.content,
247+
})),
248+
});
249+
const results = Object.fromEntries(
250+
payload.results.map((result) => [result.blockId ?? "", result]),
251+
) as ResultMap;
252+
return {
253+
sessionId,
254+
results,
255+
variables: payload.results.at(-1)?.variables ?? previousVariables,
256+
};
257+
} catch (error) {
258+
console.warn("set-ui-value failed", error);
259+
return { sessionId };
260+
}
261+
}
262+
218263
export async function preflightRuntimePackages(
219264
sessionId: string,
220265
packageNames: string[],

editor/src/lib/widgetSession.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,33 @@ export type WidgetReactiveHandler = (info: {
66
blockId: string | null;
77
}) => void | Promise<void>;
88

9+
export type WidgetUiValueHandler = (info: {
10+
blockId: string | null;
11+
elementId: string;
12+
value: unknown;
13+
}) => void | Promise<void>;
14+
915
type WidgetSessionValue = {
1016
sessionId: string | null;
1117
onReactiveTrigger?: WidgetReactiveHandler;
18+
onUiValueChange?: WidgetUiValueHandler;
1219
};
1320

1421
const WidgetSessionContext = createContext<WidgetSessionValue>({ sessionId: null });
1522

1623
export function WidgetSessionProvider({
1724
sessionId,
1825
onReactiveTrigger,
26+
onUiValueChange,
1927
children,
2028
}: {
2129
sessionId: string | null;
2230
onReactiveTrigger?: WidgetReactiveHandler;
31+
onUiValueChange?: WidgetUiValueHandler;
2332
children: ReactNode;
2433
}) {
2534
return (
26-
<WidgetSessionContext.Provider value={{ sessionId, onReactiveTrigger }}>
35+
<WidgetSessionContext.Provider value={{ sessionId, onReactiveTrigger, onUiValueChange }}>
2736
{children}
2837
</WidgetSessionContext.Provider>
2938
);
@@ -36,3 +45,7 @@ export function useWidgetSession(): string | null {
3645
export function useWidgetReactiveHandler(): WidgetReactiveHandler | undefined {
3746
return useContext(WidgetSessionContext).onReactiveTrigger;
3847
}
48+
49+
export function useWidgetUiValueChange(): WidgetUiValueHandler | undefined {
50+
return useContext(WidgetSessionContext).onUiValueChange;
51+
}

0 commit comments

Comments
 (0)