@yannelli/paseo-linear-plugin 0.1.0 → 0.3.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/NOTICE +8 -4
- package/OVERVIEW.md +22 -13
- package/README.md +134 -29
- package/client/agent-options.ts +200 -0
- package/client/breadcrumbs.tsx +110 -0
- package/client/browser.tsx +275 -0
- package/client/compat.ts +107 -0
- package/client/connect.tsx +136 -0
- package/client/create-issue.tsx +291 -0
- package/client/glyphs.tsx +117 -0
- package/client/issue-detail.tsx +366 -0
- package/client/issue-list.tsx +373 -0
- package/client/issue-row.tsx +152 -0
- package/client/issue-sections.tsx +325 -0
- package/client/issue-tree.ts +132 -0
- package/client/key-scope.tsx +23 -0
- package/client/launch-choices.ts +125 -0
- package/client/launch-fields.tsx +152 -0
- package/client/launch-plan.ts +227 -0
- package/client/launch.tsx +484 -0
- package/client/markdown.tsx +240 -0
- package/client/model-browser.tsx +296 -0
- package/client/pickers.tsx +238 -0
- package/client/provider-icon.tsx +78 -0
- package/client/queries.ts +211 -0
- package/client/settings-connection.tsx +109 -0
- package/client/settings-fields.tsx +173 -0
- package/client/settings-keys.tsx +117 -0
- package/client/settings-projects.tsx +281 -0
- package/client/settings-prompts.tsx +198 -0
- package/client/store.ts +71 -0
- package/client/timeline-card.tsx +63 -0
- package/client/ui.tsx +352 -0
- package/index.client.tsx +111 -0
- package/index.server.ts +7 -3
- package/package.json +4 -3
- package/paseo-plugin.json +2 -2
- package/server/cache.ts +68 -0
- package/server/credentials.ts +101 -0
- package/server/graphql.ts +68 -0
- package/server/handlers.ts +172 -0
- package/server/linear.ts +11 -64
- package/server/queries.ts +280 -0
- package/shared/linear.ts +268 -0
- package/shared/markdown.ts +416 -0
- package/shared/prompts.ts +150 -0
- package/shared/settings.ts +68 -0
- package/index.client.ts +0 -7
- package/server/issues.ts +0 -10
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
|
|
2
|
+
import {
|
|
3
|
+
SettingsAction,
|
|
4
|
+
SettingsCard,
|
|
5
|
+
SettingsRow,
|
|
6
|
+
SettingsSection,
|
|
7
|
+
SettingsSelect,
|
|
8
|
+
SettingsSwitch,
|
|
9
|
+
} from "@getpaseo/plugin/client/ui";
|
|
10
|
+
import { useCallback, useMemo } from "react";
|
|
11
|
+
import { View } from "react-native";
|
|
12
|
+
import { DEFAULT_TEMPLATES } from "../shared/prompts";
|
|
13
|
+
import {
|
|
14
|
+
AGENT_ACTIONS,
|
|
15
|
+
type AgentAction,
|
|
16
|
+
type Isolation,
|
|
17
|
+
type LinearSettings,
|
|
18
|
+
linearSettings,
|
|
19
|
+
} from "../shared/settings";
|
|
20
|
+
import { useProviders } from "./queries";
|
|
21
|
+
import {
|
|
22
|
+
MultilineField,
|
|
23
|
+
type ReadySettings,
|
|
24
|
+
SaveBar,
|
|
25
|
+
SettingsGate,
|
|
26
|
+
useSettingsDraft,
|
|
27
|
+
VariablesHint,
|
|
28
|
+
} from "./settings-fields";
|
|
29
|
+
import type { Theme } from "./ui";
|
|
30
|
+
|
|
31
|
+
const ISOLATION_OPTIONS: readonly { label: string; value: Isolation }[] = [
|
|
32
|
+
{ label: "New worktree on the issue branch", value: "worktree" },
|
|
33
|
+
{ label: "The project or current workspace", value: "workspace" },
|
|
34
|
+
];
|
|
35
|
+
const AUTO_MODEL = "__auto__";
|
|
36
|
+
const TEMPLATE_TITLES: Record<AgentAction, string> = {
|
|
37
|
+
implement: "Implement prompt",
|
|
38
|
+
review: "Review prompt",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type Edit = ReturnType<typeof useSettingsDraft>["edit"];
|
|
42
|
+
type Launch = LinearSettings["launch"];
|
|
43
|
+
|
|
44
|
+
export function PromptSettings({ theme }: PluginSurfaceProps) {
|
|
45
|
+
const settings = useSettings(linearSettings);
|
|
46
|
+
const render = useCallback(
|
|
47
|
+
(ready: ReadySettings) => <PromptEditor theme={theme} settings={ready} />,
|
|
48
|
+
[theme],
|
|
49
|
+
);
|
|
50
|
+
return (
|
|
51
|
+
<SettingsGate theme={theme} title="Prompts" settings={settings}>
|
|
52
|
+
{render}
|
|
53
|
+
</SettingsGate>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function useLaunchSetter<Key extends keyof Launch>(edit: Edit, key: Key) {
|
|
58
|
+
return useCallback(
|
|
59
|
+
(value: Launch[Key]) =>
|
|
60
|
+
edit((current) => ({ ...current, launch: { ...current.launch, [key]: value } })),
|
|
61
|
+
[edit, key],
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function LaunchDefaults({ launch, edit }: { launch: Launch; edit: Edit }) {
|
|
66
|
+
const providers = useProviders();
|
|
67
|
+
const modelOptions = useMemo(
|
|
68
|
+
() => [
|
|
69
|
+
{ label: "Provider default", value: AUTO_MODEL },
|
|
70
|
+
...(providers.data ?? []).flatMap((provider) =>
|
|
71
|
+
provider.models.map((model) => ({
|
|
72
|
+
label: `${provider.label} · ${model.label}`,
|
|
73
|
+
value: `${provider.id}/${model.id}`,
|
|
74
|
+
})),
|
|
75
|
+
),
|
|
76
|
+
],
|
|
77
|
+
[providers.data],
|
|
78
|
+
);
|
|
79
|
+
const setProvider = useLaunchSetter(edit, "provider");
|
|
80
|
+
const chooseModel = useCallback(
|
|
81
|
+
(value: string) => setProvider(value === AUTO_MODEL ? "" : value),
|
|
82
|
+
[setProvider],
|
|
83
|
+
);
|
|
84
|
+
const setIsolation = useLaunchSetter(edit, "isolation");
|
|
85
|
+
const setComments = useLaunchSetter(edit, "includeComments");
|
|
86
|
+
const setStarted = useLaunchSetter(edit, "moveToStarted");
|
|
87
|
+
const setAssign = useLaunchSetter(edit, "assignToMe");
|
|
88
|
+
const model = modelOptions.some((option) => option.value === launch.provider)
|
|
89
|
+
? launch.provider
|
|
90
|
+
: AUTO_MODEL;
|
|
91
|
+
return (
|
|
92
|
+
<SettingsSection title="Agent defaults">
|
|
93
|
+
<SettingsCard>
|
|
94
|
+
<SettingsSelect
|
|
95
|
+
label="Model"
|
|
96
|
+
hint="Preselected when you start an agent from an issue."
|
|
97
|
+
value={model}
|
|
98
|
+
options={modelOptions}
|
|
99
|
+
onValueChange={chooseModel}
|
|
100
|
+
/>
|
|
101
|
+
<SettingsSelect
|
|
102
|
+
label="Run agents in"
|
|
103
|
+
value={launch.isolation}
|
|
104
|
+
options={ISOLATION_OPTIONS}
|
|
105
|
+
onValueChange={setIsolation}
|
|
106
|
+
/>
|
|
107
|
+
<SettingsSwitch
|
|
108
|
+
label="Include issue comments"
|
|
109
|
+
value={launch.includeComments}
|
|
110
|
+
onValueChange={setComments}
|
|
111
|
+
/>
|
|
112
|
+
<SettingsSwitch
|
|
113
|
+
label="Move the issue to In Progress"
|
|
114
|
+
hint="Applies when an implementation agent starts."
|
|
115
|
+
value={launch.moveToStarted}
|
|
116
|
+
onValueChange={setStarted}
|
|
117
|
+
/>
|
|
118
|
+
<SettingsSwitch
|
|
119
|
+
label="Assign the issue to me"
|
|
120
|
+
hint="Applies when an implementation agent starts."
|
|
121
|
+
value={launch.assignToMe}
|
|
122
|
+
onValueChange={setAssign}
|
|
123
|
+
/>
|
|
124
|
+
</SettingsCard>
|
|
125
|
+
</SettingsSection>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function TemplateSection(props: { theme: Theme; action: AgentAction; value: string; edit: Edit }) {
|
|
130
|
+
const { action, edit } = props;
|
|
131
|
+
const setTemplate = useCallback(
|
|
132
|
+
(text: string) =>
|
|
133
|
+
edit((current) => ({ ...current, templates: { ...current.templates, [action]: text } })),
|
|
134
|
+
[edit, action],
|
|
135
|
+
);
|
|
136
|
+
const copyBuiltIn = useCallback(
|
|
137
|
+
() => setTemplate(DEFAULT_TEMPLATES[action]),
|
|
138
|
+
[setTemplate, action],
|
|
139
|
+
);
|
|
140
|
+
return (
|
|
141
|
+
<SettingsSection title={TEMPLATE_TITLES[action]}>
|
|
142
|
+
<SettingsCard>
|
|
143
|
+
<MultilineField
|
|
144
|
+
theme={props.theme}
|
|
145
|
+
label="Template"
|
|
146
|
+
hint="Leave empty to use the built-in prompt shown as the placeholder."
|
|
147
|
+
value={props.value}
|
|
148
|
+
placeholder={DEFAULT_TEMPLATES[action]}
|
|
149
|
+
minHeight={180}
|
|
150
|
+
onChange={setTemplate}
|
|
151
|
+
/>
|
|
152
|
+
<SettingsAction
|
|
153
|
+
label="Start from the built-in prompt"
|
|
154
|
+
actionLabel="Copy built-in"
|
|
155
|
+
disabled={props.value === DEFAULT_TEMPLATES[action]}
|
|
156
|
+
onPress={copyBuiltIn}
|
|
157
|
+
/>
|
|
158
|
+
</SettingsCard>
|
|
159
|
+
</SettingsSection>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function PromptEditor({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
|
|
164
|
+
const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
|
|
165
|
+
const saveDraft = useCallback(() => void save(), [save]);
|
|
166
|
+
return (
|
|
167
|
+
<View>
|
|
168
|
+
<LaunchDefaults launch={values.launch} edit={edit} />
|
|
169
|
+
{AGENT_ACTIONS.map((action) => (
|
|
170
|
+
<TemplateSection
|
|
171
|
+
key={action}
|
|
172
|
+
theme={theme}
|
|
173
|
+
action={action}
|
|
174
|
+
value={values.templates[action]}
|
|
175
|
+
edit={edit}
|
|
176
|
+
/>
|
|
177
|
+
))}
|
|
178
|
+
<SettingsSection title="Template variables">
|
|
179
|
+
<SettingsCard>
|
|
180
|
+
<SettingsRow
|
|
181
|
+
label="Available in every template"
|
|
182
|
+
hint="Projects can append to or replace these prompts in Linear projects settings."
|
|
183
|
+
>
|
|
184
|
+
<VariablesHint theme={theme} />
|
|
185
|
+
</SettingsRow>
|
|
186
|
+
</SettingsCard>
|
|
187
|
+
</SettingsSection>
|
|
188
|
+
<SaveBar
|
|
189
|
+
theme={theme}
|
|
190
|
+
dirty={dirty}
|
|
191
|
+
saving={settings.saving}
|
|
192
|
+
error={settings.saveError}
|
|
193
|
+
onSave={saveDraft}
|
|
194
|
+
onDiscard={discard}
|
|
195
|
+
/>
|
|
196
|
+
</View>
|
|
197
|
+
);
|
|
198
|
+
}
|
package/client/store.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
import type { AssigneeFilter, IssueSort, StatusFilter } from "../shared/linear";
|
|
3
|
+
import type { AgentAction } from "../shared/settings";
|
|
4
|
+
|
|
5
|
+
// In-memory navigation state per browser scope ("screen" or a workspace panel). Paseo 0.10
|
|
6
|
+
// screens take no params, so commands write the focused issue here before opening a surface.
|
|
7
|
+
export interface BrowserState {
|
|
8
|
+
issueId: string | null;
|
|
9
|
+
teamId: string | null;
|
|
10
|
+
assignee: AssigneeFilter;
|
|
11
|
+
status: StatusFilter;
|
|
12
|
+
query: string;
|
|
13
|
+
sort: IssueSort;
|
|
14
|
+
/** Shows sub-issues under their parent when both are in the list. */
|
|
15
|
+
nested: boolean;
|
|
16
|
+
/** Parent issues whose sub-issues are hidden. */
|
|
17
|
+
collapsed: readonly string[];
|
|
18
|
+
/** Screen only: the Paseo project whose Linear key to use. Panels use their workspace's project. */
|
|
19
|
+
keyProjectId: string | null;
|
|
20
|
+
creating: boolean;
|
|
21
|
+
/** The agent setup page, open over the whole browser. */
|
|
22
|
+
launch: { issueId: string; action: AgentAction } | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const SCREEN_SCOPE = "screen";
|
|
26
|
+
export const panelScope = (workspaceId: string) => `panel:${workspaceId}`;
|
|
27
|
+
|
|
28
|
+
const INITIAL: BrowserState = {
|
|
29
|
+
issueId: null,
|
|
30
|
+
teamId: null,
|
|
31
|
+
assignee: "me",
|
|
32
|
+
status: "active",
|
|
33
|
+
query: "",
|
|
34
|
+
sort: "updated",
|
|
35
|
+
nested: true,
|
|
36
|
+
collapsed: [],
|
|
37
|
+
keyProjectId: null,
|
|
38
|
+
creating: false,
|
|
39
|
+
launch: null,
|
|
40
|
+
};
|
|
41
|
+
const states = new Map<string, BrowserState>();
|
|
42
|
+
const listeners = new Set<() => void>();
|
|
43
|
+
|
|
44
|
+
function read(scope: string): BrowserState {
|
|
45
|
+
return states.get(scope) ?? INITIAL;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function updateBrowser(scope: string, patch: Partial<BrowserState>): void {
|
|
49
|
+
states.set(scope, { ...read(scope), ...patch });
|
|
50
|
+
for (const listener of listeners) listener();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function focusIssue(scope: string, issueId: string | null): void {
|
|
54
|
+
updateBrowser(scope, { issueId, creating: false, launch: null });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useBrowserState(scope: string) {
|
|
58
|
+
const state = useSyncExternalStore(
|
|
59
|
+
(listener) => {
|
|
60
|
+
listeners.add(listener);
|
|
61
|
+
return () => listeners.delete(listener);
|
|
62
|
+
},
|
|
63
|
+
() => read(scope),
|
|
64
|
+
() => read(scope),
|
|
65
|
+
);
|
|
66
|
+
const update = useCallback(
|
|
67
|
+
(patch: Partial<BrowserState>) => updateBrowser(scope, patch),
|
|
68
|
+
[scope],
|
|
69
|
+
);
|
|
70
|
+
return [state, update] as const;
|
|
71
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { openExternalUrl, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { useCallback, useMemo } from "react";
|
|
4
|
+
import { Pressable, Text, View } from "react-native";
|
|
5
|
+
import type { IssueCard } from "../shared/linear";
|
|
6
|
+
import { ACTION_LABELS } from "../shared/prompts";
|
|
7
|
+
import { usePressableStyle } from "./ui";
|
|
8
|
+
|
|
9
|
+
export function IssueCardRow({ item, theme }: PluginTimelineItemProps<IssueCard>) {
|
|
10
|
+
const card = item.data;
|
|
11
|
+
const { colors } = theme;
|
|
12
|
+
const styles = useMemo(() => {
|
|
13
|
+
const idle = {
|
|
14
|
+
alignSelf: "flex-start" as const,
|
|
15
|
+
maxWidth: "100%" as const,
|
|
16
|
+
marginVertical: 4,
|
|
17
|
+
paddingHorizontal: 12,
|
|
18
|
+
paddingVertical: 10,
|
|
19
|
+
borderRadius: 10,
|
|
20
|
+
borderWidth: 1,
|
|
21
|
+
borderColor: colors.border,
|
|
22
|
+
backgroundColor: colors.surface1,
|
|
23
|
+
flexDirection: "row" as const,
|
|
24
|
+
alignItems: "center" as const,
|
|
25
|
+
gap: 10,
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
idle,
|
|
29
|
+
active: { ...idle, backgroundColor: colors.surface2 },
|
|
30
|
+
body: { flexShrink: 1, gap: 2 },
|
|
31
|
+
title: { color: colors.foreground, fontSize: 14 },
|
|
32
|
+
identifier: { color: colors.foregroundMuted },
|
|
33
|
+
meta: { flexDirection: "row" as const, alignItems: "center" as const, gap: 6 },
|
|
34
|
+
dot: { width: 7, height: 7, borderRadius: 4, backgroundColor: card.stateColor },
|
|
35
|
+
detail: { color: colors.foregroundMuted, fontSize: 12 },
|
|
36
|
+
};
|
|
37
|
+
}, [colors, card.stateColor]);
|
|
38
|
+
const style = usePressableStyle(styles.idle, styles.active);
|
|
39
|
+
const open = useCallback(() => void openExternalUrl(card.url), [card.url]);
|
|
40
|
+
return (
|
|
41
|
+
<Pressable
|
|
42
|
+
accessibilityRole="link"
|
|
43
|
+
accessibilityLabel={`Linear issue ${card.identifier}: ${card.title}. Open in Linear`}
|
|
44
|
+
onPress={open}
|
|
45
|
+
style={style}
|
|
46
|
+
>
|
|
47
|
+
<Icon name="SquareKanban" size={16} color={colors.foregroundMuted} />
|
|
48
|
+
<View style={styles.body}>
|
|
49
|
+
<Text numberOfLines={1} style={styles.title}>
|
|
50
|
+
<Text style={styles.identifier}>{`${card.identifier} `}</Text>
|
|
51
|
+
{card.title}
|
|
52
|
+
</Text>
|
|
53
|
+
<View style={styles.meta}>
|
|
54
|
+
<View style={styles.dot} />
|
|
55
|
+
<Text style={styles.detail}>
|
|
56
|
+
{`${card.stateName} · ${ACTION_LABELS[card.action].title} agent started from Linear`}
|
|
57
|
+
</Text>
|
|
58
|
+
</View>
|
|
59
|
+
</View>
|
|
60
|
+
<Icon name="ExternalLink" size={14} color={colors.foregroundMuted} />
|
|
61
|
+
</Pressable>
|
|
62
|
+
);
|
|
63
|
+
}
|
package/client/ui.tsx
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { type ReactNode, useCallback, useMemo } from "react";
|
|
4
|
+
import {
|
|
5
|
+
ActivityIndicator,
|
|
6
|
+
Pressable,
|
|
7
|
+
type PressableStateCallbackType,
|
|
8
|
+
type StyleProp,
|
|
9
|
+
Text,
|
|
10
|
+
View,
|
|
11
|
+
type ViewStyle,
|
|
12
|
+
} from "react-native";
|
|
13
|
+
|
|
14
|
+
export { Avatar, LabelPill, PriorityIcon, priorityMeta, StateIcon } from "./glyphs";
|
|
15
|
+
|
|
16
|
+
export type Theme = PluginSurfaceProps["theme"];
|
|
17
|
+
|
|
18
|
+
type InteractionState = PressableStateCallbackType & { hovered?: boolean };
|
|
19
|
+
|
|
20
|
+
export function interactive(state: PressableStateCallbackType): boolean {
|
|
21
|
+
return state.pressed || (state as InteractionState).hovered === true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Stable Pressable style callback: `idle` normally, `active` while hovered or pressed.
|
|
25
|
+
// Callers pass memoized styles so the callback only changes when the styles do.
|
|
26
|
+
export function usePressableStyle(idle: StyleProp<ViewStyle>, active: StyleProp<ViewStyle>) {
|
|
27
|
+
return useCallback(
|
|
28
|
+
(pressable: PressableStateCallbackType) => (interactive(pressable) ? active : idle),
|
|
29
|
+
[idle, active],
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const HEIGHT = { xs: 28, sm: 32, md: 40 } as const;
|
|
34
|
+
|
|
35
|
+
type ButtonVariant = "primary" | "secondary" | "outline" | "ghost";
|
|
36
|
+
|
|
37
|
+
function buttonFill(variant: ButtonVariant, colors: Theme["colors"]): string {
|
|
38
|
+
if (variant === "primary") return colors.accent;
|
|
39
|
+
if (variant === "secondary") return colors.surface2;
|
|
40
|
+
return "transparent";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ButtonProps {
|
|
44
|
+
theme: Theme;
|
|
45
|
+
label: string;
|
|
46
|
+
icon?: string;
|
|
47
|
+
variant?: ButtonVariant;
|
|
48
|
+
size?: keyof typeof HEIGHT;
|
|
49
|
+
disabled?: boolean;
|
|
50
|
+
busy?: boolean;
|
|
51
|
+
accessibilityLabel?: string;
|
|
52
|
+
onPress(): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ButtonGlyph(props: { busy: boolean; icon?: string; size: number; color: string }) {
|
|
56
|
+
if (props.busy) return <ActivityIndicator size="small" color={props.color} />;
|
|
57
|
+
if (!props.icon) return null;
|
|
58
|
+
return <Icon name={props.icon} size={props.size} color={props.color} />;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function Button({
|
|
62
|
+
theme,
|
|
63
|
+
label,
|
|
64
|
+
icon,
|
|
65
|
+
variant = "secondary",
|
|
66
|
+
size = "sm",
|
|
67
|
+
disabled,
|
|
68
|
+
busy,
|
|
69
|
+
accessibilityLabel,
|
|
70
|
+
onPress,
|
|
71
|
+
}: ButtonProps) {
|
|
72
|
+
const colors = theme.colors;
|
|
73
|
+
const ink = variant === "primary" ? colors.accentForeground : colors.foreground;
|
|
74
|
+
const inactive = disabled === true || busy === true;
|
|
75
|
+
const styles = useMemo(() => {
|
|
76
|
+
const idle = {
|
|
77
|
+
height: HEIGHT[size],
|
|
78
|
+
paddingHorizontal: size === "xs" ? 8 : 12,
|
|
79
|
+
borderRadius: 8,
|
|
80
|
+
flexDirection: "row",
|
|
81
|
+
alignItems: "center",
|
|
82
|
+
justifyContent: "center",
|
|
83
|
+
gap: 6,
|
|
84
|
+
backgroundColor: buttonFill(variant, colors),
|
|
85
|
+
borderWidth: variant === "outline" ? 1 : 0,
|
|
86
|
+
borderColor: colors.border,
|
|
87
|
+
opacity: inactive ? 0.5 : 1,
|
|
88
|
+
} as const;
|
|
89
|
+
return {
|
|
90
|
+
idle,
|
|
91
|
+
active: { ...idle, opacity: inactive ? 0.5 : 0.85 },
|
|
92
|
+
label: { color: ink, fontSize: size === "xs" ? 12 : 14 },
|
|
93
|
+
};
|
|
94
|
+
}, [colors, variant, size, inactive, ink]);
|
|
95
|
+
const pressStyle = usePressableStyle(styles.idle, styles.active);
|
|
96
|
+
const a11yState = useMemo(() => ({ disabled: inactive, busy: busy === true }), [inactive, busy]);
|
|
97
|
+
return (
|
|
98
|
+
<Pressable
|
|
99
|
+
accessibilityRole="button"
|
|
100
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
101
|
+
accessibilityState={a11yState}
|
|
102
|
+
disabled={inactive}
|
|
103
|
+
onPress={onPress}
|
|
104
|
+
style={pressStyle}
|
|
105
|
+
>
|
|
106
|
+
<ButtonGlyph busy={busy === true} icon={icon} size={size === "xs" ? 14 : 16} color={ink} />
|
|
107
|
+
<Text numberOfLines={1} style={styles.label}>
|
|
108
|
+
{label}
|
|
109
|
+
</Text>
|
|
110
|
+
</Pressable>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function IconButton(props: {
|
|
115
|
+
theme: Theme;
|
|
116
|
+
icon: string;
|
|
117
|
+
label: string;
|
|
118
|
+
onPress(): void;
|
|
119
|
+
disabled?: boolean;
|
|
120
|
+
}) {
|
|
121
|
+
const { colors } = props.theme;
|
|
122
|
+
const styles = useMemo(() => {
|
|
123
|
+
const idle = {
|
|
124
|
+
width: 32,
|
|
125
|
+
height: 32,
|
|
126
|
+
borderRadius: 8,
|
|
127
|
+
alignItems: "center",
|
|
128
|
+
justifyContent: "center",
|
|
129
|
+
backgroundColor: "transparent",
|
|
130
|
+
opacity: props.disabled ? 0.5 : 1,
|
|
131
|
+
} as const;
|
|
132
|
+
return { idle, active: { ...idle, backgroundColor: colors.surface2 } };
|
|
133
|
+
}, [colors, props.disabled]);
|
|
134
|
+
const pressStyle = usePressableStyle(styles.idle, styles.active);
|
|
135
|
+
return (
|
|
136
|
+
<Pressable
|
|
137
|
+
accessibilityRole="button"
|
|
138
|
+
accessibilityLabel={props.label}
|
|
139
|
+
disabled={props.disabled}
|
|
140
|
+
onPress={props.onPress}
|
|
141
|
+
hitSlop={6}
|
|
142
|
+
style={pressStyle}
|
|
143
|
+
>
|
|
144
|
+
<Icon name={props.icon} size={16} color={colors.foregroundMuted} />
|
|
145
|
+
</Pressable>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function SegmentTab<Value extends string>(props: {
|
|
150
|
+
theme: Theme;
|
|
151
|
+
value: Value;
|
|
152
|
+
label: string;
|
|
153
|
+
selected: boolean;
|
|
154
|
+
onChange(value: Value): void;
|
|
155
|
+
}) {
|
|
156
|
+
const { colors } = props.theme;
|
|
157
|
+
const { selected, value, onChange } = props;
|
|
158
|
+
const styles = useMemo(() => {
|
|
159
|
+
const tab = {
|
|
160
|
+
height: 26,
|
|
161
|
+
paddingHorizontal: 10,
|
|
162
|
+
borderRadius: 6,
|
|
163
|
+
justifyContent: "center",
|
|
164
|
+
backgroundColor: "transparent",
|
|
165
|
+
} as const;
|
|
166
|
+
const active = { ...tab, backgroundColor: colors.surface2 };
|
|
167
|
+
return {
|
|
168
|
+
idle: selected ? active : tab,
|
|
169
|
+
active,
|
|
170
|
+
label: { fontSize: 12, color: selected ? colors.foreground : colors.foregroundMuted },
|
|
171
|
+
};
|
|
172
|
+
}, [colors, selected]);
|
|
173
|
+
const pressStyle = usePressableStyle(styles.idle, styles.active);
|
|
174
|
+
const a11yState = useMemo(() => ({ selected }), [selected]);
|
|
175
|
+
const press = useCallback(() => onChange(value), [onChange, value]);
|
|
176
|
+
return (
|
|
177
|
+
<Pressable
|
|
178
|
+
accessibilityRole="tab"
|
|
179
|
+
accessibilityState={a11yState}
|
|
180
|
+
onPress={press}
|
|
181
|
+
style={pressStyle}
|
|
182
|
+
>
|
|
183
|
+
<Text style={styles.label}>{props.label}</Text>
|
|
184
|
+
</Pressable>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function Segmented<Value extends string>(props: {
|
|
189
|
+
theme: Theme;
|
|
190
|
+
value: Value;
|
|
191
|
+
options: readonly { value: Value; label: string }[];
|
|
192
|
+
onChange(value: Value): void;
|
|
193
|
+
accessibilityLabel: string;
|
|
194
|
+
}) {
|
|
195
|
+
const { colors } = props.theme;
|
|
196
|
+
const style = useMemo(
|
|
197
|
+
() =>
|
|
198
|
+
({
|
|
199
|
+
flexDirection: "row",
|
|
200
|
+
gap: 2,
|
|
201
|
+
padding: 2,
|
|
202
|
+
borderRadius: 8,
|
|
203
|
+
backgroundColor: colors.surface1,
|
|
204
|
+
}) as const,
|
|
205
|
+
[colors],
|
|
206
|
+
);
|
|
207
|
+
return (
|
|
208
|
+
<View accessibilityRole="tablist" accessibilityLabel={props.accessibilityLabel} style={style}>
|
|
209
|
+
{props.options.map((option) => (
|
|
210
|
+
<SegmentTab
|
|
211
|
+
key={option.value}
|
|
212
|
+
theme={props.theme}
|
|
213
|
+
value={option.value}
|
|
214
|
+
label={option.label}
|
|
215
|
+
selected={option.value === props.value}
|
|
216
|
+
onChange={props.onChange}
|
|
217
|
+
/>
|
|
218
|
+
))}
|
|
219
|
+
</View>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function OptionRow(props: {
|
|
224
|
+
theme: Theme;
|
|
225
|
+
label: string;
|
|
226
|
+
detail?: string;
|
|
227
|
+
leading?: ReactNode;
|
|
228
|
+
selected?: boolean;
|
|
229
|
+
onPress(): void;
|
|
230
|
+
}) {
|
|
231
|
+
const { colors } = props.theme;
|
|
232
|
+
const selected = props.selected === true;
|
|
233
|
+
const styles = useMemo(() => {
|
|
234
|
+
const idle = {
|
|
235
|
+
minHeight: 40,
|
|
236
|
+
paddingHorizontal: 12,
|
|
237
|
+
paddingVertical: 8,
|
|
238
|
+
borderRadius: 8,
|
|
239
|
+
flexDirection: "row",
|
|
240
|
+
alignItems: "center",
|
|
241
|
+
gap: 10,
|
|
242
|
+
backgroundColor: "transparent",
|
|
243
|
+
} as const;
|
|
244
|
+
return {
|
|
245
|
+
idle,
|
|
246
|
+
active: { ...idle, backgroundColor: colors.surface2 },
|
|
247
|
+
body: { flex: 1, minWidth: 0 },
|
|
248
|
+
label: { color: colors.foreground, fontSize: 14 },
|
|
249
|
+
detail: { color: colors.foregroundMuted, fontSize: 12 },
|
|
250
|
+
};
|
|
251
|
+
}, [colors]);
|
|
252
|
+
const pressStyle = usePressableStyle(styles.idle, styles.active);
|
|
253
|
+
const a11yState = useMemo(() => ({ selected }), [selected]);
|
|
254
|
+
return (
|
|
255
|
+
<Pressable
|
|
256
|
+
accessibilityRole="button"
|
|
257
|
+
accessibilityState={a11yState}
|
|
258
|
+
onPress={props.onPress}
|
|
259
|
+
style={pressStyle}
|
|
260
|
+
>
|
|
261
|
+
{props.leading}
|
|
262
|
+
<View style={styles.body}>
|
|
263
|
+
<Text numberOfLines={1} style={styles.label}>
|
|
264
|
+
{props.label}
|
|
265
|
+
</Text>
|
|
266
|
+
{props.detail ? (
|
|
267
|
+
<Text numberOfLines={1} style={styles.detail}>
|
|
268
|
+
{props.detail}
|
|
269
|
+
</Text>
|
|
270
|
+
) : null}
|
|
271
|
+
</View>
|
|
272
|
+
{selected ? <Icon name="Check" size={16} color={colors.foreground} /> : null}
|
|
273
|
+
</Pressable>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function EmptyState(props: {
|
|
278
|
+
theme: Theme;
|
|
279
|
+
icon: string;
|
|
280
|
+
title: string;
|
|
281
|
+
detail?: string;
|
|
282
|
+
tone?: "default" | "danger";
|
|
283
|
+
children?: ReactNode;
|
|
284
|
+
}) {
|
|
285
|
+
const { colors } = props.theme;
|
|
286
|
+
const styles = useMemo(
|
|
287
|
+
() => ({
|
|
288
|
+
root: {
|
|
289
|
+
flex: 1,
|
|
290
|
+
alignItems: "center" as const,
|
|
291
|
+
justifyContent: "center" as const,
|
|
292
|
+
gap: 10,
|
|
293
|
+
padding: 32,
|
|
294
|
+
},
|
|
295
|
+
title: {
|
|
296
|
+
color: props.tone === "danger" ? colors.statusDanger : colors.foreground,
|
|
297
|
+
fontSize: 15,
|
|
298
|
+
fontWeight: "500" as const,
|
|
299
|
+
textAlign: "center" as const,
|
|
300
|
+
},
|
|
301
|
+
detail: {
|
|
302
|
+
color: colors.foregroundMuted,
|
|
303
|
+
fontSize: 13,
|
|
304
|
+
textAlign: "center" as const,
|
|
305
|
+
maxWidth: 420,
|
|
306
|
+
},
|
|
307
|
+
}),
|
|
308
|
+
[colors, props.tone],
|
|
309
|
+
);
|
|
310
|
+
return (
|
|
311
|
+
<View style={styles.root}>
|
|
312
|
+
<Icon name={props.icon} size={28} color={colors.foregroundMuted} />
|
|
313
|
+
<Text style={styles.title}>{props.title}</Text>
|
|
314
|
+
{props.detail ? <Text style={styles.detail}>{props.detail}</Text> : null}
|
|
315
|
+
{props.children}
|
|
316
|
+
</View>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function SectionLabel({ theme, children }: { theme: Theme; children: ReactNode }) {
|
|
321
|
+
const { colors } = theme;
|
|
322
|
+
const style = useMemo(
|
|
323
|
+
() =>
|
|
324
|
+
({
|
|
325
|
+
color: colors.foregroundMuted,
|
|
326
|
+
fontSize: 12,
|
|
327
|
+
fontWeight: "500",
|
|
328
|
+
marginBottom: 6,
|
|
329
|
+
}) as const,
|
|
330
|
+
[colors],
|
|
331
|
+
);
|
|
332
|
+
return <Text style={style}>{children}</Text>;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function errorMessage(error: unknown): string {
|
|
336
|
+
return error instanceof Error ? error.message : String(error);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function relativeTime(iso: string, now: number = Date.now()): string {
|
|
340
|
+
const seconds = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
|
|
341
|
+
if (seconds < 60) return "just now";
|
|
342
|
+
const minutes = Math.round(seconds / 60);
|
|
343
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
344
|
+
const hours = Math.round(minutes / 60);
|
|
345
|
+
if (hours < 24) return `${hours}h ago`;
|
|
346
|
+
const days = Math.round(hours / 24);
|
|
347
|
+
if (days < 30) return `${days}d ago`;
|
|
348
|
+
const date = new Date(iso);
|
|
349
|
+
const month = date.toLocaleString("en-US", { month: "short" });
|
|
350
|
+
if (date.getFullYear() === new Date(now).getFullYear()) return `${month} ${date.getDate()}`;
|
|
351
|
+
return `${month} ${date.getFullYear()}`;
|
|
352
|
+
}
|