@asterism-app/sdk 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/README.md +20 -2
- package/docs/data-and-lifecycle.md +79 -0
- package/docs/getting-started.md +37 -0
- package/docs/manual.md +38 -0
- package/docs/minimal-plugin.md +35 -0
- package/docs/project-structure.md +33 -0
- package/docs/resources-and-tools.md +43 -0
- package/docs/session-entries.md +31 -0
- package/docs/views-and-settings.md +43 -0
- package/examples/author.ts +158 -0
- package/examples/consume-usage.ts +29 -0
- package/examples/content-author.ts +29 -0
- package/examples/content-contract.ts +10 -0
- package/examples/content-renderer.ts +26 -0
- package/examples/counter/docs/usage.md +9 -0
- package/examples/counter/package.json +27 -0
- package/examples/counter/src/contracts.ts +34 -0
- package/examples/counter/src/panel.module.css +2 -0
- package/examples/counter/src/renderer.tsx +80 -0
- package/examples/counter/src/runtime.ts +49 -0
- package/examples/counter/src/storage.ts +4 -0
- package/examples/counter/src/styles.d.ts +4 -0
- package/examples/counter/tsconfig.json +16 -0
- package/examples/counter-contract.ts +18 -0
- package/examples/data-codecs.ts +18 -0
- package/examples/events.ts +37 -0
- package/examples/i18n.ts +18 -0
- package/examples/model-completion.ts +15 -0
- package/examples/model-input.ts +16 -0
- package/examples/plugin-calls.ts +22 -0
- package/examples/provider-usage-headers.ts +20 -0
- package/examples/provider-usage.ts +34 -0
- package/examples/renderer.ts +47 -0
- package/examples/session-resources.ts +29 -0
- package/examples/settings-contract.ts +8 -0
- package/examples/settings-renderer.ts +30 -0
- package/examples/settings-runtime.ts +11 -0
- package/examples/storage.ts +26 -0
- package/examples/tool-renderer.ts +47 -0
- package/examples/web-provider.ts +27 -0
- package/package.json +39 -4
- package/src/calls.ts +28 -0
- package/src/card.ts +7 -0
- package/src/channels.ts +20 -0
- package/src/commands.ts +28 -0
- package/src/config.ts +25 -0
- package/src/content-runtime.ts +11 -0
- package/src/content.ts +6 -0
- package/src/contracts.ts +39 -0
- package/src/data-encoding.ts +14 -0
- package/src/error-details.ts +78 -0
- package/src/events.ts +91 -0
- package/src/i18n.ts +58 -0
- package/src/json.ts +4 -0
- package/src/mcp.ts +80 -0
- package/src/model-catalog.ts +18 -0
- package/src/model-cost-estimate.ts +49 -0
- package/src/model-cost.ts +44 -0
- package/src/model-input.ts +19 -0
- package/src/model-media.ts +11 -0
- package/src/model-provider.ts +44 -0
- package/src/model-records.ts +56 -0
- package/src/model-selection.ts +34 -0
- package/src/models.ts +134 -0
- package/src/module-contract.ts +44 -0
- package/src/objects.ts +89 -0
- package/src/plugin.ts +58 -0
- package/src/presentation.ts +21 -0
- package/src/provider-usage.ts +47 -0
- package/src/records.ts +60 -0
- package/src/renderer-calls.ts +15 -0
- package/src/renderer-card.ts +29 -0
- package/src/renderer-content.ts +9 -0
- package/src/renderer-creation.ts +83 -0
- package/src/renderer-i18n.ts +49 -0
- package/src/renderer-island.ts +66 -0
- package/src/renderer-records.ts +49 -0
- package/src/renderer-services.ts +89 -0
- package/src/renderer-tools.ts +103 -0
- package/src/renderer-view-state.ts +33 -0
- package/src/renderer.ts +174 -0
- package/src/runtime-agent-catalog.ts +27 -0
- package/src/runtime-application.ts +7 -0
- package/src/runtime-calls.ts +22 -0
- package/src/runtime-channels.ts +24 -0
- package/src/runtime-contracts.ts +207 -0
- package/src/runtime-mcp.ts +24 -0
- package/src/runtime-model-providers.ts +16 -0
- package/src/runtime-resources.ts +117 -0
- package/src/runtime-sessions.ts +36 -0
- package/src/runtime-shell.ts +18 -0
- package/src/runtime-terminal.ts +24 -0
- package/src/runtime-tools.ts +31 -0
- package/src/runtime-usage.ts +54 -0
- package/src/runtime-web.ts +36 -0
- package/src/runtime.ts +53 -0
- package/src/session-title.ts +8 -0
- package/src/shell.ts +76 -0
- package/src/terminal.ts +36 -0
- package/src/tool-result.ts +45 -0
- package/src/tool-validation.ts +14 -0
- package/src/usage-state.ts +20 -0
- package/src/web.ts +75 -0
- package/src/workspace.ts +43 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { uiTextSchema } from "./i18n.ts";
|
|
3
|
+
|
|
4
|
+
/** Usage implementations have global catalog identities, independent of configured model connections. */
|
|
5
|
+
export const providerUsageIdSchema = z.string().trim().min(1).max(160).regex(/^\S+$/u);
|
|
6
|
+
|
|
7
|
+
const identity = {
|
|
8
|
+
id: z.string().trim().min(1), label: uiTextSchema,
|
|
9
|
+
scope: z.enum(["account", "plan", "key", "connection"]),
|
|
10
|
+
};
|
|
11
|
+
export const quotaValueSchema = z.discriminatedUnion("type", [
|
|
12
|
+
z.strictObject({ type: z.literal("ratio"), remaining: z.number().min(0).max(1) }),
|
|
13
|
+
z.strictObject({ type: z.literal("quantity"), remaining: z.number().nonnegative(), limit: z.number().positive(), unit: uiTextSchema })
|
|
14
|
+
.refine(value => value.remaining <= value.limit, "Remaining quota cannot exceed its limit."),
|
|
15
|
+
]);
|
|
16
|
+
export const usageItemSchema = z.discriminatedUnion("type", [
|
|
17
|
+
z.strictObject({ ...identity, type: z.literal("balance"), remaining: z.number(), currency: z.string().regex(/^[A-Z]{3}$/u) }),
|
|
18
|
+
z.strictObject({ ...identity, type: z.literal("quota"), value: quotaValueSchema,
|
|
19
|
+
category: z.enum(["allowance", "rate_limit"]).optional(),
|
|
20
|
+
window: z.strictObject({
|
|
21
|
+
mode: z.enum(["rolling", "fixed", "unknown"]),
|
|
22
|
+
/** Supply an actual fixed duration only when known; unknown periods must not be fabricated as weeks or months. */
|
|
23
|
+
durationMs: z.number().positive().nullable(),
|
|
24
|
+
resetAt: z.number().nonnegative().nullable(),
|
|
25
|
+
}),
|
|
26
|
+
}),
|
|
27
|
+
]);
|
|
28
|
+
export const usageItemsSchema = z.array(usageItemSchema).refine(items => new Set(items.map(item => item.id)).size === items.length,
|
|
29
|
+
"Usage item IDs must be unique.");
|
|
30
|
+
export type UsageItem = z.infer<typeof usageItemSchema>;
|
|
31
|
+
export type QuotaItem = Extract<UsageItem, { type: "quota" }>;
|
|
32
|
+
export const providerUsageErrorSchema = z.enum(["unavailable", "credential", "authentication", "unsupported", "rate_limit", "network", "timeout", "protocol", "server"]);
|
|
33
|
+
export type ProviderUsageError = z.infer<typeof providerUsageErrorSchema>;
|
|
34
|
+
export const usageProviderInfoSchema = z.strictObject({ id: providerUsageIdSchema, label: uiTextSchema, owner: z.string(),
|
|
35
|
+
source: z.enum(["query", "headers"]).default("query") });
|
|
36
|
+
export type UsageProviderInfo = z.infer<typeof usageProviderInfoSchema>;
|
|
37
|
+
|
|
38
|
+
export function quotaRemaining(item: QuotaItem): number {
|
|
39
|
+
return item.value.type === "ratio" ? item.value.remaining : item.value.remaining / item.value.limit;
|
|
40
|
+
}
|
|
41
|
+
/** Select the window with the shortest period, independently of the reset countdown; unknown periods follow known ones, and equal lengths retain source order. */
|
|
42
|
+
export function primaryQuota(items: readonly UsageItem[]): QuotaItem | null {
|
|
43
|
+
let selected: QuotaItem | null = null;
|
|
44
|
+
for (const item of items) if (item.type === "quota" && (!selected ||
|
|
45
|
+
(item.window.durationMs ?? Infinity) < (selected.window.durationMs ?? Infinity))) selected = item;
|
|
46
|
+
return selected;
|
|
47
|
+
}
|
package/src/records.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { Json } from "./json.ts";
|
|
3
|
+
import type { Source } from "./workspace.ts";
|
|
4
|
+
|
|
5
|
+
/** Public envelope for session records: the host maps its storage format to this envelope, and plugins decode by contract without depending on internal node structure. */
|
|
6
|
+
export interface RecordRef { readonly id: string }
|
|
7
|
+
export interface SessionRecord {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly sessionId: string;
|
|
10
|
+
readonly parentId: string | null;
|
|
11
|
+
/** Append order within the session, starting at 1. */
|
|
12
|
+
readonly sequence: number;
|
|
13
|
+
readonly createdAt: string;
|
|
14
|
+
readonly contract: RecordRef;
|
|
15
|
+
/** Writer; stamped by the host according to the write channel, while the associated object belongs in data. */
|
|
16
|
+
readonly source: Source;
|
|
17
|
+
/** Model-visible text written by the platform, consumed when executors project context; recipient being null means all executors. */
|
|
18
|
+
readonly context: { readonly text: string; readonly recipient: string | null } | null;
|
|
19
|
+
readonly data: Json;
|
|
20
|
+
}
|
|
21
|
+
export type Decoded<D> =
|
|
22
|
+
| { readonly status: "known"; readonly record: SessionRecord; readonly data: D }
|
|
23
|
+
| { readonly status: "unknown"; readonly record: SessionRecord; readonly reason: "invalid_data" };
|
|
24
|
+
export interface RecordContract<D extends Json = Json> extends RecordRef {
|
|
25
|
+
/** Decodes persisted JSON into D; record writers encode D through the same schema. */
|
|
26
|
+
readonly schema: z.ZodType<D>;
|
|
27
|
+
/** Return null when the record does not belong to this contract; compatibility and transformations are determined by the schema, and failure preserves the raw record. */
|
|
28
|
+
decode(record: SessionRecord): Decoded<D> | null;
|
|
29
|
+
}
|
|
30
|
+
export function defineRecord<D extends Json>(id: string, definition: {
|
|
31
|
+
schema: z.ZodType<D>;
|
|
32
|
+
}): RecordContract<D> {
|
|
33
|
+
const { schema } = definition;
|
|
34
|
+
return {
|
|
35
|
+
id, schema,
|
|
36
|
+
decode(record) {
|
|
37
|
+
if (record.contract.id !== id) return null;
|
|
38
|
+
const data = structuredClone(record.data);
|
|
39
|
+
try {
|
|
40
|
+
const parsed = schema.safeParse(data);
|
|
41
|
+
return parsed.success ? { status: "known", record, data: parsed.data } : { status: "unknown", record, reason: "invalid_data" };
|
|
42
|
+
} catch { return { status: "unknown", record, reason: "invalid_data" }; }
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** timeline reads the complete ancestor path to the snapshot tip; session reads all session records within the snapshot's upper bound in save order. */
|
|
47
|
+
export interface RecordQuery { readonly scope: "timeline" | "session" }
|
|
48
|
+
/** An observation handle fixed at one read position; reads records on demand and does not switch to the latest state after await. */
|
|
49
|
+
export interface SessionView {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly title: string;
|
|
52
|
+
readonly records: {
|
|
53
|
+
all(query: RecordQuery): readonly SessionRecord[];
|
|
54
|
+
/** The earliest matching record; returns unknown if it cannot be decoded, without skipping it. */
|
|
55
|
+
first<D extends Json>(contract: RecordContract<D>, query: RecordQuery): Decoded<D> | null;
|
|
56
|
+
/** The latest matching record, located by scope and contract; decodes only that record and returns unknown if undecodable, without falling back. */
|
|
57
|
+
last<D extends Json>(contract: RecordContract<D>, query: RecordQuery): Decoded<D> | null;
|
|
58
|
+
query<D extends Json>(contract: RecordContract<D>, query: RecordQuery): Decoded<D>[];
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { CallContract, PluginCalls } from "./calls.ts";
|
|
3
|
+
import type { RendererContentInput } from "./renderer-content.ts";
|
|
4
|
+
|
|
5
|
+
export interface RendererCalls extends PluginCalls<RendererContentInput> {
|
|
6
|
+
input(contract: CallContract): RendererContentInput;
|
|
7
|
+
}
|
|
8
|
+
const Calls = createContext<RendererCalls | null>(null);
|
|
9
|
+
export const RendererCallsProvider = Calls.Provider;
|
|
10
|
+
/** Invoke a registered backend contract; associating an object does not change resource ownership. */
|
|
11
|
+
export function useRendererCalls(): RendererCalls {
|
|
12
|
+
const calls = useContext(Calls);
|
|
13
|
+
if (!calls) throw new Error("Renderer calls must be provided by the host.");
|
|
14
|
+
return calls;
|
|
15
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import type { UiText } from "./i18n.ts";
|
|
3
|
+
|
|
4
|
+
/** Auxiliary panel identities are unique within a single card renderer; content receives the same validated projection as the body. */
|
|
5
|
+
export interface CardPanelDefinition<T> {
|
|
6
|
+
id: string;
|
|
7
|
+
label: UiText;
|
|
8
|
+
icon: ReactNode;
|
|
9
|
+
Body: ComponentType<{ data: T }>;
|
|
10
|
+
}
|
|
11
|
+
export interface CardPanelView {
|
|
12
|
+
id: string;
|
|
13
|
+
label: UiText;
|
|
14
|
+
icon: ReactNode;
|
|
15
|
+
body: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
/** Local card view operations; do not submit resource commands or write task history. */
|
|
18
|
+
export interface RendererCard {
|
|
19
|
+
readonly activePanel: string | null;
|
|
20
|
+
openPanel(id: string): void;
|
|
21
|
+
closePanel(): void;
|
|
22
|
+
}
|
|
23
|
+
const CardContext = createContext<RendererCard | null>(null);
|
|
24
|
+
export const RendererCardProvider = CardContext.Provider;
|
|
25
|
+
export function useRendererCard(): RendererCard {
|
|
26
|
+
const card = useContext(CardContext);
|
|
27
|
+
if (!card) throw new Error("The renderer must be bound to a card.");
|
|
28
|
+
return card;
|
|
29
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ContentRef } from "./content.ts";
|
|
2
|
+
import type { ContentReader } from "./content-runtime.ts";
|
|
3
|
+
|
|
4
|
+
/** The host binds input drafts to contracts; business input carries only ContentRef. The host closes drafts after command success and preserves them for retry after failure. */
|
|
5
|
+
export interface RendererContentInput extends ContentReader {
|
|
6
|
+
stage(source: Blob | Uint8Array, options?: { signal?: AbortSignal; progress?(bytes: number, total: number): void }): Promise<ContentRef>;
|
|
7
|
+
close(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export interface RendererInputOptions { content?: RendererContentInput }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import type { CreationContract } from "./commands.ts";
|
|
4
|
+
import type { Json } from "./json.ts";
|
|
5
|
+
import type { RendererContentInput } from "./renderer-content.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Host snapshot of dropped content: the host reads it from DataTransfer at drop time, and it remains valid after the event ends.
|
|
9
|
+
* The host resolves file paths through the desktop bridge; plugins cannot forge them.
|
|
10
|
+
*/
|
|
11
|
+
export type DropItem =
|
|
12
|
+
/** Local drops carry a host-resolved path; sources without paths, such as clipboard images, contain only file bytes. */
|
|
13
|
+
| { readonly kind: "file"; readonly file: File; readonly name: string; readonly mime: string; readonly directory: boolean; readonly path?: string }
|
|
14
|
+
| { readonly kind: "url"; readonly url: string }
|
|
15
|
+
| { readonly kind: "text"; readonly text: string };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Declarative acceptance conditions: the host matches them synchronously during drag hover without calling plugin functions.
|
|
19
|
+
* Extensions include a leading dot and use lowercase; MIME supports wildcards such as `image/*`.
|
|
20
|
+
*/
|
|
21
|
+
export interface DropAccepts {
|
|
22
|
+
/** requiresPath accepts only items with a local path; directories implicitly requires a path. */
|
|
23
|
+
readonly files?: { readonly requiresPath?: boolean; readonly directories?: boolean; readonly extensions?: readonly string[]; readonly mime?: readonly string[] };
|
|
24
|
+
readonly urls?: { readonly schemes?: readonly string[] };
|
|
25
|
+
readonly text?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The source that triggered the entry point; menus have no payload. */
|
|
29
|
+
export type CreationSource = { readonly kind: "menu" } | { readonly kind: "drop"; readonly item: DropItem };
|
|
30
|
+
|
|
31
|
+
/** Plugin-owned preferences: the host isolates them by plugin and persists them in UI state; reads validate using the caller's schema. */
|
|
32
|
+
export interface RendererPreferences {
|
|
33
|
+
get<T extends Json>(key: string, schema: z.ZodType<T>): T | null;
|
|
34
|
+
set(key: string, value: Json | null): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Input phase component: can render any UI, such as forms or previews, and can only return input or cancel.
|
|
39
|
+
* resolve returns whether host creation succeeded; on failure the component stays open and the host shell displays the error.
|
|
40
|
+
*/
|
|
41
|
+
export interface CreationInputProps<I = Json> {
|
|
42
|
+
readonly source: CreationSource;
|
|
43
|
+
readonly initialInput?: I;
|
|
44
|
+
readonly busy: boolean;
|
|
45
|
+
readonly content: RendererContentInput;
|
|
46
|
+
resolve(input: I): Promise<boolean>;
|
|
47
|
+
cancel(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CreationEntry<I = Json> {
|
|
51
|
+
/** Globally stable identity using the declaring plugin's namespace; routing memory stores this as its value. */
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly contract: CreationContract<I, Json, Json>;
|
|
54
|
+
readonly label: UiText;
|
|
55
|
+
readonly description?: UiText;
|
|
56
|
+
readonly icon?: ReactNode;
|
|
57
|
+
/** Menu group; defaults to resources when omitted. */
|
|
58
|
+
readonly group?: "agents" | "resources";
|
|
59
|
+
readonly triggers: {
|
|
60
|
+
readonly menu?: { readonly initialInput?: I };
|
|
61
|
+
readonly drop?: { readonly accepts: DropAccepts; toInput(item: DropItem, content: RendererContentInput): I | null | Promise<I | null> };
|
|
62
|
+
};
|
|
63
|
+
/** When omitted, input is produced directly from the trigger source; menu sources must then provide initialInput. */
|
|
64
|
+
readonly InputView?: ComponentType<CreationInputProps<I>>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Type erasure occurs only at the registration boundary; call and component input types are inferred from the creation contract. */
|
|
68
|
+
export function creationEntry<I, O extends Json>(contract: CreationContract<I, Json, O>,
|
|
69
|
+
definition: Omit<CreationEntry<NoInfer<I> & Json>, "contract">): CreationEntry {
|
|
70
|
+
if (!definition.triggers.menu && !definition.triggers.drop) throw new Error(`Creation entry ${definition.id} declares no trigger.`);
|
|
71
|
+
if (definition.triggers.menu && !definition.InputView && definition.triggers.menu.initialInput === undefined)
|
|
72
|
+
throw new Error(`Creation entry ${definition.id} needs an InputView or a menu initialInput.`);
|
|
73
|
+
return { ...definition, contract } as unknown as CreationEntry;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const Preferences = createContext<RendererPreferences | null>(null);
|
|
77
|
+
export const RendererPreferencesProvider = Preferences.Provider;
|
|
78
|
+
export function useRendererPreferences(): RendererPreferences {
|
|
79
|
+
const value = useContext(Preferences);
|
|
80
|
+
if (!value) throw new Error("Renderer preferences must be provided by the host.");
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
import type { UiText } from "./i18n.ts";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createContext, createElement, useContext, type ReactNode } from "react";
|
|
2
|
+
import { createIntl, createIntlCache, RawIntlProvider, FormattedMessage } from "react-intl";
|
|
3
|
+
|
|
4
|
+
import type { UiText, UiMessage, MessageValues } from "./i18n.ts";
|
|
5
|
+
|
|
6
|
+
export interface I18n {
|
|
7
|
+
readonly locale: string;
|
|
8
|
+
readonly interfaceLocale: string;
|
|
9
|
+
readonly revision: number;
|
|
10
|
+
text(value: UiText): string;
|
|
11
|
+
formatMessage(message: UiMessage, values?: MessageValues): string;
|
|
12
|
+
formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
|
|
13
|
+
formatDate(value: number, options?: Intl.DateTimeFormatOptions): string;
|
|
14
|
+
resolve(message: UiMessage): { locale: string; message: string; revision: string };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const I18nContext = createContext<I18n | null>(null);
|
|
18
|
+
|
|
19
|
+
export function I18nProvider({ value, children }: { value: I18n; children: ReactNode }) {
|
|
20
|
+
return createElement(I18nContext.Provider, { value }, children);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useI18n(): I18n {
|
|
24
|
+
const value = useContext(I18nContext);
|
|
25
|
+
if (!value) throw new Error("The renderer requires the host I18nProvider.");
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const cache = createIntlCache();
|
|
30
|
+
|
|
31
|
+
/** Rich-text mappings remain in React; default copy is still declared and extracted through standard defineMessages. */
|
|
32
|
+
export function UiFormattedMessage({ message, values }: {
|
|
33
|
+
message: UiMessage;
|
|
34
|
+
values?: Record<string, string | number | boolean | ((chunks: ReactNode[]) => ReactNode)>;
|
|
35
|
+
}) {
|
|
36
|
+
const i18n = useI18n();
|
|
37
|
+
const selected = i18n.resolve(message);
|
|
38
|
+
const intl = createIntl({
|
|
39
|
+
locale: selected.locale,
|
|
40
|
+
defaultLocale: message.sourceLocale,
|
|
41
|
+
messages: { [message.id]: selected.message },
|
|
42
|
+
onError: error => console.warn("Rich text message formatting failed.", error),
|
|
43
|
+
}, cache);
|
|
44
|
+
return createElement(RawIntlProvider, { value: intl }, createElement(FormattedMessage, {
|
|
45
|
+
id: message.id,
|
|
46
|
+
defaultMessage: message.defaultMessage,
|
|
47
|
+
values: { ...message.values, ...values },
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createContext, createElement, useContext, useEffect, useRef, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import type { UiText } from "./i18n.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Island: an independent surface column within the host screen; opening one places it to the right of the current island and replaces existing deeper islands.
|
|
6
|
+
* The host uniformly manages width, position, motion, and focus; plugins provide only identity, an accessible name, and content.
|
|
7
|
+
*/
|
|
8
|
+
export interface RendererIslandSpec {
|
|
9
|
+
/** Island identity unique within the plugin; the host isolates it by plugin identity. Opening the same identity again replaces its content. */
|
|
10
|
+
key: string;
|
|
11
|
+
/** The island's accessible name and overflow navigation hint. */
|
|
12
|
+
label: UiText;
|
|
13
|
+
/** The element that opened it: used for the expansion animation and receives focus again after closing. */
|
|
14
|
+
origin?: HTMLElement | null;
|
|
15
|
+
render: () => ReactNode;
|
|
16
|
+
}
|
|
17
|
+
/** Close guard: returning false (or resolving to false) aborts closing; may asynchronously wait for user confirmation. */
|
|
18
|
+
export type RendererCloseGuard = () => boolean | Promise<boolean>;
|
|
19
|
+
export interface RendererIsland {
|
|
20
|
+
/** Identities of deeper islands opened by this plugin to the right of this island. */
|
|
21
|
+
readonly child: string | undefined;
|
|
22
|
+
/** Open a deeper island to the right of this one; replaced islands may refuse through guards. Resolves to whether it opened. */
|
|
23
|
+
open(spec: RendererIslandSpec): Promise<boolean>;
|
|
24
|
+
/** Close this island and deeper islands; has no effect on the host-managed island containing the plugin settings page. force skips guards. */
|
|
25
|
+
close(options?: { force?: boolean }): Promise<boolean>;
|
|
26
|
+
/** Close deeper islands to the right of this one. */
|
|
27
|
+
closeChild(options?: { force?: boolean }): Promise<boolean>;
|
|
28
|
+
/** Register a close guard for this island and return an unregister function. Normally used through useRendererCloseGuard. */
|
|
29
|
+
guard(guard: RendererCloseGuard): () => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Panel island frame: the title bar stays fixed and the content scrolls independently; the primary action and close control sit on the right of the title bar.
|
|
33
|
+
* When dirty is true, the host asks for confirmation to discard changes before closing this island, including through ancestor closure or replacement.
|
|
34
|
+
*/
|
|
35
|
+
export interface RendererIslandPanelProps {
|
|
36
|
+
title: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
/** Primary title bar action, such as save. */
|
|
39
|
+
action?: ReactNode;
|
|
40
|
+
/** Fixed toolbar below the title bar, such as search. */
|
|
41
|
+
toolbar?: ReactNode;
|
|
42
|
+
dirty?: boolean;
|
|
43
|
+
children: ReactNode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const Island = createContext<RendererIsland | null>(null);
|
|
47
|
+
const Panel = createContext<ComponentType<RendererIslandPanelProps> | null>(null);
|
|
48
|
+
export const RendererIslandProvider = Island.Provider;
|
|
49
|
+
export const RendererIslandPanelProvider = Panel.Provider;
|
|
50
|
+
function required<T>(value: T | null, name: string): T {
|
|
51
|
+
if (value === null) throw new Error(`Renderer ${name} must be provided by the host.`);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
/** The containing island; available within plugin settings pages and islands they open. */
|
|
55
|
+
export function useRendererIsland(): RendererIsland { return required(useContext(Island), "island"); }
|
|
56
|
+
/** Register a close guard for the containing island; null guard permits closing, and the guard always uses the latest callback. */
|
|
57
|
+
export function useRendererCloseGuard(guard: RendererCloseGuard | null) {
|
|
58
|
+
const island = useRendererIsland();
|
|
59
|
+
const latest = useRef(guard);
|
|
60
|
+
latest.current = guard;
|
|
61
|
+
useEffect(() => island.guard(() => latest.current ? latest.current() : true), [island]);
|
|
62
|
+
}
|
|
63
|
+
/** Host-rendered panel frame for use within islands opened by plugins. */
|
|
64
|
+
export function IslandPanel(props: RendererIslandPanelProps) {
|
|
65
|
+
return createElement(required(useContext(Panel), "island panel"), props);
|
|
66
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createElement, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import type { UiText } from "./i18n.ts";
|
|
3
|
+
import type { Json } from "./json.ts";
|
|
4
|
+
import { reference, type ContractReference } from "./presentation.ts";
|
|
5
|
+
import type { RecordContract, SessionRecord } from "./records.ts";
|
|
6
|
+
|
|
7
|
+
/** Host-provided queries: records carry only identities, while the host resolves titles and type names using the full history. */
|
|
8
|
+
export interface RecordRenderContext {
|
|
9
|
+
/** An object's title across the entire history; objects that were removed or belong to other branches can still be resolved. */
|
|
10
|
+
resourceTitle(id: string): string;
|
|
11
|
+
resourceLabel(kind: string): UiText;
|
|
12
|
+
/** Format copy as plain text in the current UI language for composing summaries. */
|
|
13
|
+
text(value: UiText): string;
|
|
14
|
+
/** Contracts wrapping other records borrow the wrapped record's view; return null when unregistered or undecodable. */
|
|
15
|
+
describe(record: SessionRecord): RecordView | null;
|
|
16
|
+
}
|
|
17
|
+
export interface RecordRowProps<D> { data: D; record: SessionRecord; label: string }
|
|
18
|
+
export interface RecordRendererDefinition<D> {
|
|
19
|
+
/** Required plain-text summary for search, tooltips, and accessible names. */
|
|
20
|
+
label(data: D, context: RecordRenderContext, record: SessionRecord): UiText;
|
|
21
|
+
icon?: ReactNode | ((data: D, record: SessionRecord) => ReactNode);
|
|
22
|
+
/** Optional summary row content; the host owns the row shell, including selection, position marker, source, and menu. */
|
|
23
|
+
Row?: ComponentType<RecordRowProps<D>>;
|
|
24
|
+
}
|
|
25
|
+
export interface RecordView {
|
|
26
|
+
label: UiText;
|
|
27
|
+
icon?: ReactNode;
|
|
28
|
+
/** Generate row content from the host-formatted summary; the host displays the summary text when omitted. */
|
|
29
|
+
row?(label: string): ReactNode;
|
|
30
|
+
}
|
|
31
|
+
export interface RecordRenderer {
|
|
32
|
+
contract: ContractReference;
|
|
33
|
+
resolve(record: SessionRecord, context: RecordRenderContext): RecordView | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Declare history presentation by record contract; data enters the view only after contract decoding, otherwise the host displays an unknown record. */
|
|
37
|
+
export function recordRenderer<D extends Json>(contract: RecordContract<D>, definition: RecordRendererDefinition<D>): RecordRenderer {
|
|
38
|
+
return { contract: reference(contract), resolve(record, context) {
|
|
39
|
+
const decoded = contract.decode(record);
|
|
40
|
+
if (decoded?.status !== "known") return null;
|
|
41
|
+
const { data } = decoded;
|
|
42
|
+
const { Row, icon } = definition;
|
|
43
|
+
return {
|
|
44
|
+
label: definition.label(data, context, record),
|
|
45
|
+
icon: typeof icon === "function" ? icon(data, record) : icon,
|
|
46
|
+
row: Row ? label => createElement(Row, { data, record, label }) : undefined,
|
|
47
|
+
};
|
|
48
|
+
} };
|
|
49
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { Json } from "./json.ts";
|
|
3
|
+
import type { DataContract } from "./presentation.ts";
|
|
4
|
+
import type { ConfigContract } from "./config.ts";
|
|
5
|
+
import type { ModelLiveBlock } from "./models.ts";
|
|
6
|
+
import type { ToolRenderProps, ToolView } from "./renderer-tools.ts";
|
|
7
|
+
import type { EventSubscriber } from "./events.ts";
|
|
8
|
+
import type { ResourceDescription } from "./renderer.ts";
|
|
9
|
+
import type { UiText } from "./i18n.ts";
|
|
10
|
+
|
|
11
|
+
/** Static resource type presentation catalog; querying descriptions grants no access to resource instances. */
|
|
12
|
+
export interface RendererResources {
|
|
13
|
+
list(): readonly ResourceDescription[];
|
|
14
|
+
get(kind: string): ResourceDescription;
|
|
15
|
+
}
|
|
16
|
+
export interface RendererCatalogs { get<T extends Json>(contract: DataContract<T>): T | null; error: UiText | null }
|
|
17
|
+
export interface RendererTools {
|
|
18
|
+
resolve(props: ToolRenderProps): ToolView & { props: ToolRenderProps };
|
|
19
|
+
preparation(name: string): string;
|
|
20
|
+
}
|
|
21
|
+
export interface RendererModels { readonly blocks: readonly ModelLiveBlock[] }
|
|
22
|
+
export interface RendererNavigation { home(): void }
|
|
23
|
+
const Resources = createContext<RendererResources | null>(null);
|
|
24
|
+
const Catalogs = createContext<RendererCatalogs | null>(null);
|
|
25
|
+
const Tools = createContext<RendererTools | null>(null);
|
|
26
|
+
const Models = createContext<RendererModels | null>(null);
|
|
27
|
+
const Navigation = createContext<RendererNavigation | null>(null);
|
|
28
|
+
const Events = createContext<EventSubscriber | null>(null);
|
|
29
|
+
export const RendererEventsProvider = Events.Provider;
|
|
30
|
+
/** Global typed subscriptions; components should close subscriptions they establish during effect cleanup. */
|
|
31
|
+
export function useRendererEvents(): EventSubscriber { return required(useContext(Events), "events"); }
|
|
32
|
+
export const RendererResourcesProvider = Resources.Provider;
|
|
33
|
+
export const RendererCatalogsProvider = Catalogs.Provider;
|
|
34
|
+
export const RendererToolsProvider = Tools.Provider;
|
|
35
|
+
export const RendererModelsProvider = Models.Provider;
|
|
36
|
+
export const RendererNavigationProvider = Navigation.Provider;
|
|
37
|
+
function required<T>(value: T | null, name: string): T {
|
|
38
|
+
if (value === null) throw new Error(`Renderer ${name} must be provided by the host.`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
export function useRendererResources(): RendererResources { return required(useContext(Resources), "resources"); }
|
|
42
|
+
export function useRendererCatalogs(): RendererCatalogs { return required(useContext(Catalogs), "catalogs"); }
|
|
43
|
+
export function useRendererTools(): RendererTools { return required(useContext(Tools), "tools"); }
|
|
44
|
+
export function useRendererModels(): RendererModels { return required(useContext(Models), "models"); }
|
|
45
|
+
export function useRendererNavigation(): RendererNavigation { return required(useContext(Navigation), "navigation"); }
|
|
46
|
+
|
|
47
|
+
/** Renderer access to plugin configuration: the host decodes by contract and updates on change notifications after settings are saved; can read only this plugin's configuration. */
|
|
48
|
+
export interface RendererConfiguration<T extends Json> {
|
|
49
|
+
value: T;
|
|
50
|
+
status: "saved" | "defaults" | "invalid";
|
|
51
|
+
ready: boolean;
|
|
52
|
+
error: UiText | null;
|
|
53
|
+
}
|
|
54
|
+
export interface RendererConfigs {
|
|
55
|
+
get<T extends Json>(contract: ConfigContract<T>): RendererConfiguration<T>;
|
|
56
|
+
save<T extends Json>(contract: ConfigContract<T>, value: NoInfer<T>): Promise<void>;
|
|
57
|
+
reset(contract: ConfigContract): Promise<void>;
|
|
58
|
+
refresh(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
const Configs = createContext<RendererConfigs | null>(null);
|
|
61
|
+
export const RendererConfigsProvider = Configs.Provider;
|
|
62
|
+
export function useRendererConfig<T extends Json>(contract: ConfigContract<T>): T {
|
|
63
|
+
return required(useContext(Configs), "configs").get(contract).value;
|
|
64
|
+
}
|
|
65
|
+
/** Custom settings pages use managed configuration; writes are validated in the backend, and successful writes synchronously publish a new snapshot. */
|
|
66
|
+
export function useRendererConfiguration<T extends Json>(contract: ConfigContract<T>) {
|
|
67
|
+
const configs = required(useContext(Configs), "configs");
|
|
68
|
+
return { ...configs.get(contract), save: (value: T) => configs.save(contract, value), reset: () => configs.reset(contract), refresh: configs.refresh };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface RendererSettingsNavigation {
|
|
72
|
+
open(pluginId: string, pageId: string, location?: string): void;
|
|
73
|
+
openHost(pageId: string): void;
|
|
74
|
+
}
|
|
75
|
+
const SettingsNavigation = createContext<RendererSettingsNavigation | null>(null);
|
|
76
|
+
export const RendererSettingsNavigationProvider = SettingsNavigation.Provider;
|
|
77
|
+
/** Open plugin settings without binding the current resource or session. */
|
|
78
|
+
export function useRendererSettings(): RendererSettingsNavigation { return required(useContext(SettingsNavigation), "settings navigation"); }
|
|
79
|
+
|
|
80
|
+
/** Application-wide frontend capabilities shared by plugin interfaces. */
|
|
81
|
+
export interface RendererApplicationServices {
|
|
82
|
+
readonly platform: "darwin" | "win32" | "linux";
|
|
83
|
+
preferences(pluginId: string): import("./renderer-creation.ts").RendererPreferences;
|
|
84
|
+
pickDirectory(initialPath?: string): Promise<string | null>;
|
|
85
|
+
openLocalFile(path: string): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
const Application = createContext<RendererApplicationServices | null>(null);
|
|
88
|
+
export const RendererApplicationProvider = Application.Provider;
|
|
89
|
+
export function useRendererApplication(): RendererApplicationServices { return required(useContext(Application), "application"); }
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createElement, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import type { UiText } from "./i18n.ts";
|
|
4
|
+
import type { Json } from "./json.ts";
|
|
5
|
+
import { reference, type ContractReference, type DataContract } from "./presentation.ts";
|
|
6
|
+
import type { ToolCompletion, ToolInvocationInfo, ToolResult } from "./tool-result.ts";
|
|
7
|
+
|
|
8
|
+
interface ToolCallProps<T, A> {
|
|
9
|
+
name: string;
|
|
10
|
+
target: T;
|
|
11
|
+
args: A;
|
|
12
|
+
invocation: ToolInvocationInfo;
|
|
13
|
+
}
|
|
14
|
+
/** Partial JSON has not passed the argument contract; fields may be missing, incomplete, or of the wrong type. */
|
|
15
|
+
export interface ToolInputRenderProps extends ToolCallProps<Json, Json> {
|
|
16
|
+
phase: "input";
|
|
17
|
+
result: null;
|
|
18
|
+
}
|
|
19
|
+
/** Waiting for scheduling and execution share a view; complete arguments have passed contract validation. */
|
|
20
|
+
export interface ToolExecutionRenderProps<D = Json, T = Json, A = Json> extends ToolCallProps<T, A> {
|
|
21
|
+
phase: "ready" | "executing";
|
|
22
|
+
result: Extract<ToolResult<D>, { kind: "running" }> | null;
|
|
23
|
+
}
|
|
24
|
+
export interface ToolResultRenderProps<D = Json, T = Json, A = Json> extends ToolCallProps<T, A> {
|
|
25
|
+
phase: "settled";
|
|
26
|
+
result: ToolCompletion<D>;
|
|
27
|
+
}
|
|
28
|
+
export type ToolRenderProps<D = Json, T = Json, A = Json> =
|
|
29
|
+
| ToolInputRenderProps | ToolExecutionRenderProps<D, T, A> | ToolResultRenderProps<D, T, A>;
|
|
30
|
+
|
|
31
|
+
/** All three states use the same two independently optional entry points; the same declaration can be reused across phases. */
|
|
32
|
+
export interface ToolPhaseView<P> {
|
|
33
|
+
headerRenderer?: ComponentType<P>;
|
|
34
|
+
bodyRenderer?: ComponentType<P>;
|
|
35
|
+
}
|
|
36
|
+
/** Input and execution views declare previewable content; bodyRenderer still owns the body layout. */
|
|
37
|
+
export interface ToolStreamingPhaseView<P> extends ToolPhaseView<P> {
|
|
38
|
+
/** Pure function: returns true when a body suitable for streaming preview exists; after the first match within a phase, the shell manages expansion and collapse according to user preferences. */
|
|
39
|
+
streaming?: (props: P) => boolean;
|
|
40
|
+
}
|
|
41
|
+
/** Execution views may additionally declare bodies that require user attention. */
|
|
42
|
+
export interface ToolExecutionPhaseView<P> extends ToolStreamingPhaseView<P> {
|
|
43
|
+
/** Pure function: returns true when the body requires user attention; the shell immediately expands once on the first match during execution, independently of streaming preferences, and users can still collapse it. */
|
|
44
|
+
attention?: (props: P) => boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface ToolRendererDefinition<D = Json, T = Json, A = Json> {
|
|
47
|
+
label?: UiText;
|
|
48
|
+
icon?: ReactNode;
|
|
49
|
+
input?: ToolStreamingPhaseView<ToolInputRenderProps>;
|
|
50
|
+
execution?: ToolExecutionPhaseView<ToolExecutionRenderProps<D, T, A>>;
|
|
51
|
+
result?: ToolPhaseView<ToolResultRenderProps<D, T, A>>;
|
|
52
|
+
}
|
|
53
|
+
export interface ToolView {
|
|
54
|
+
label?: UiText;
|
|
55
|
+
icon?: ReactNode;
|
|
56
|
+
header?: ReactNode;
|
|
57
|
+
body?: ReactNode;
|
|
58
|
+
/** Bodies in input or execution phases may participate in streaming preview expansion and collapse; false means no match this time, and phase completion is determined by the call lifecycle. */
|
|
59
|
+
streaming?: boolean;
|
|
60
|
+
/** The executing body requires user attention; the shell expands once when this first becomes true within the phase. */
|
|
61
|
+
attention?: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface ToolRenderer {
|
|
64
|
+
contract: ContractReference;
|
|
65
|
+
resolve(props: ToolRenderProps): ToolView | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function render<P extends { phase: ToolRenderProps["phase"] }>(
|
|
69
|
+
definition: Pick<ToolView, "label" | "icon">, view: ToolExecutionPhaseView<P> | undefined, props: P,
|
|
70
|
+
): ToolView {
|
|
71
|
+
return {
|
|
72
|
+
label: definition.label, icon: definition.icon,
|
|
73
|
+
header: view?.headerRenderer ? createElement(view.headerRenderer, props) : undefined,
|
|
74
|
+
body: view?.bodyRenderer ? createElement(view.bodyRenderer, props) : undefined,
|
|
75
|
+
streaming: (props.phase === "input" || props.phase === "executing")
|
|
76
|
+
&& !!view?.bodyRenderer && (view.streaming?.(props) ?? false),
|
|
77
|
+
attention: props.phase === "executing" && !!view?.bodyRenderer && (view.attention?.(props) ?? false),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Dispatch by phase; streaming input is not treated as valid arguments, while execution and results share complete argument and output validation. */
|
|
82
|
+
export function toolRenderer<D extends Json, T extends Json = Json, A extends Json = Json>(
|
|
83
|
+
contract: DataContract<D> & { targetSchema?: z.ZodType<T>; tool?: { argsSchema: z.ZodType<A> } },
|
|
84
|
+
definition: ToolRendererDefinition<D, T, A>,
|
|
85
|
+
): ToolRenderer {
|
|
86
|
+
return { contract: reference(contract), resolve(props) {
|
|
87
|
+
if (props.phase === "input") return definition.input ? render(definition, definition.input, props) : null;
|
|
88
|
+
const data = props.result?.data;
|
|
89
|
+
const parsed = data === null || data === undefined ? null : contract.schema.safeParse(data);
|
|
90
|
+
if (parsed && !parsed.success) return null;
|
|
91
|
+
const target = contract.targetSchema?.safeParse(props.target);
|
|
92
|
+
const args = contract.tool?.argsSchema.safeParse(props.args);
|
|
93
|
+
if (target && !target.success || args && !args.success) return null;
|
|
94
|
+
// Pure data contracts without an argument schema preserve raw JSON; type erasure occurs only at the registration boundary.
|
|
95
|
+
const input = { target: (target ? target.data : props.target) as T, args: (args ? args.data : props.args) as A };
|
|
96
|
+
if (props.phase === "settled") return render(definition, definition.result, {
|
|
97
|
+
...props, ...input, result: { ...props.result, data: parsed?.data ?? null },
|
|
98
|
+
});
|
|
99
|
+
return render(definition, definition.execution, {
|
|
100
|
+
...props, ...input, result: props.result ? { ...props.result, data: parsed?.data ?? null } : null,
|
|
101
|
+
});
|
|
102
|
+
} };
|
|
103
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createContext, createElement, useContext, useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* List item view state: items in virtualized lists unmount and remount as they scroll; user-modified expansion, tabs, and other
|
|
5
|
+
* state are stored per item in storage supplied by the list owner and restored on remount. Outside an item scope, this behaves like component state.
|
|
6
|
+
*/
|
|
7
|
+
export type RendererViewStateEntry = Map<string, unknown>;
|
|
8
|
+
const Entry = createContext<RendererViewStateEntry | null>(null);
|
|
9
|
+
|
|
10
|
+
/** The list owner provides stable storage for each item; the same instance must be passed when that item remounts. */
|
|
11
|
+
export function RendererViewStateProvider({ value, children }: { value: RendererViewStateEntry; children?: ReactNode }) {
|
|
12
|
+
return createElement(Entry.Provider, { value }, children);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read and write one view state value within the current item. key is unique within the item and prefixed with the plugin ID to avoid conflicts with other contributors;
|
|
17
|
+
* when key is null, this is only component state and does not survive remounts. initial is used only when the item has no such value yet.
|
|
18
|
+
*/
|
|
19
|
+
export function useRendererViewState<T>(key: string | null, initial: T | (() => T)): [T, (value: T) => void] {
|
|
20
|
+
const entry = useContext(Entry);
|
|
21
|
+
const stored = key !== null && entry !== null;
|
|
22
|
+
const [value, setLocal] = useState<T>(() => {
|
|
23
|
+
if (stored && entry.has(key)) return entry.get(key) as T;
|
|
24
|
+
const created = typeof initial === "function" ? (initial as () => T)() : initial;
|
|
25
|
+
if (stored) entry.set(key, created);
|
|
26
|
+
return created;
|
|
27
|
+
});
|
|
28
|
+
const set = (next: T) => {
|
|
29
|
+
if (stored) entry.set(key, next);
|
|
30
|
+
setLocal(next);
|
|
31
|
+
};
|
|
32
|
+
return [value, set];
|
|
33
|
+
}
|