mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
feat(studio): first-run ends with the project step and a hand-over: drop or pick a folder, the card shrinks away, the composer rises and types its prompt
This commit is contained in:
1 parent
c4375bcde9
commit
c457700233
12 files changed
+190
-11
No files matched your search
@@ -416,6 +416,8 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "capability.scope", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "model.select", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "network.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "onboarding.pick-folder", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "onboarding.start", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "onboarding.demo", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "onboarding.connect", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "transcript.scroll", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -112,3 +112,20 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onb-stage::before, .onb-stage::after, .onb-welcome-mark, .onb-welcome > *, .onb-demo * { animation: none !important; }
|
||||
}
|
||||
.onb-drop { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; min-height: 150px; padding: 20px; border: 1.5px dashed var(--border); border-radius: var(--r-md); background: transparent; color: var(--muted); font: var(--w-med) var(--fs-ui) var(--ui); text-align: center; transition: background-color 160ms var(--ease-out), border-color 160ms var(--ease-out); }
|
||||
.onb-drop > .studio-icon { width: 22px; height: 22px; color: var(--faint); }
|
||||
.onb-drop[data-over] { border-color: var(--accent); background: var(--overlay); animation: onb-dash 1.2s linear infinite; background-image: repeating-linear-gradient(45deg, transparent 0 8px, color-mix(in srgb, var(--accent) 6%, transparent) 8px 16px); background-size: 22.6px 22.6px; }
|
||||
.onb-drop[data-chosen] { border-style: solid; border-color: var(--accent); background: var(--raised); }
|
||||
.onb-chosen { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 2px 10px; max-width: 100%; text-align: start; animation: onb-land 260ms var(--ease-out) both; }
|
||||
.onb-chosen > .studio-icon { grid-row: 1 / span 2; width: 22px; height: 22px; color: var(--text); }
|
||||
.onb-chosen > b { color: var(--text); font: var(--w-semi) var(--fs-ui) var(--ui); }
|
||||
.onb-chosen > small { overflow: hidden; color: var(--faint); font: var(--w-reg) var(--fs-meta) var(--mono); text-overflow: ellipsis; white-space: nowrap; }
|
||||
@keyframes onb-dash { to { background-position: 22.6px 0; } }
|
||||
@keyframes onb-land { from { opacity: 0; transform: translateY(-12px) scale(.96); } }
|
||||
@keyframes onb-leave { to { opacity: 0; transform: scale(.94); } }
|
||||
@keyframes compose-rise { from { opacity: 0; transform: translateY(24px); } }
|
||||
.onb-stage[data-leaving] .onb-shell { animation: onb-leave 240ms ease-in forwards; }
|
||||
.compose[data-rise] { animation: compose-rise 320ms var(--ease-out) both; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onb-drop[data-over], .onb-chosen, .onb-stage[data-leaving] .onb-shell, .compose[data-rise] { animation: none !important; }
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from "./types";
|
||||
|
||||
export const ONBOARDING: Messages = {
|
||||
"onboarding.choose_a_folder": { zh: "选择文件夹…", en: "Choose a folder…" },
|
||||
"onboarding.choose_another": { zh: "换一个…", en: "Choose another…" },
|
||||
"onboarding.choose_the_default_model": { zh: "选默认模型", en: "Choose the default model" },
|
||||
"onboarding.common_services": { zh: "常用服务", en: "Common services" },
|
||||
"onboarding.connect": { zh: "连接", en: "Connect" },
|
||||
@@ -11,6 +13,8 @@ export const ONBOARDING: Messages = {
|
||||
"onboarding.demo_read": { zh: "读取", en: "Read" },
|
||||
"onboarding.demo_run": { zh: "运行命令", en: "Run" },
|
||||
"onboarding.demo_thinking": { zh: "思考中", en: "Thinking" },
|
||||
"onboarding.drop_a_folder_here": { zh: "把文件夹拖到这里", en: "Drop a folder here" },
|
||||
"onboarding.drop_to_choose": { zh: "松手就选这个文件夹", en: "Let go to choose this folder" },
|
||||
"onboarding.every_step_stays_on_the": { zh: "每一步均可追溯。", en: "Every step stays on the record." },
|
||||
"onboarding.https_your_host_v1": { zh: "https://你的地址/v1", en: "https://your-host/v1" },
|
||||
"onboarding.local_first_credentials_stay_in": { zh: "本地优先 · 凭据由系统安全存储 · 连接前不会发送请求", en: "Local first · credentials stay in secure system storage · nothing is sent before you connect" },
|
||||
@@ -26,6 +30,7 @@ export const ONBOARDING: Messages = {
|
||||
"onboarding.siliconflow": { zh: "硅基流动", en: "SiliconFlow" },
|
||||
"onboarding.welcome_to_reasonix": { zh: "欢迎使用 Reasonix。", en: "Welcome to Reasonix." },
|
||||
"onboarding.which_file_changed_which_command": { zh: "修改了哪个文件、运行了哪条命令、消耗了多少 token,均可回溯查看。", en: "Which file changed, which command ran, how many tokens it cost — all of it can be read back." },
|
||||
"onboarding.which_project": { zh: "在哪个项目里干活?", en: "Which project will you work in?" },
|
||||
"onboarding.you_hand_it_a_task": { zh: "交付一项任务,由它完整执行。", en: "You hand it a task, and it sees the task through." },
|
||||
"onboarding.zhipu": { zh: "智谱", en: "Zhipu" },
|
||||
"onboarding.hand_it_a_task": { zh: "欢迎使用 Reasonix", en: "Welcome to Reasonix" },
|
||||
|
||||
@@ -9,7 +9,7 @@ const REGISTERED: Record<string, string> = {
|
||||
"step-in": "Think-Exec", "step-pop": "Think-Exec", "step-shake": "Think-Exec",
|
||||
"activity-shimmer": "Think-Exec", "think-sweep": "Think-Exec", "bump-up": "Think-Exec", "caret-blink": "Think-Exec",
|
||||
"demo-in": "Onb-Motion", "demo-gone": "Onb-Motion", "demo-out": "Onb-Motion", "demo-draw": "Onb-Motion",
|
||||
"onb-mark-in": "Onb-Motion", "onb-rise": "Onb-Motion", "onb-breathe": "Onb-Motion", "onb-grid": "Onb-Motion", "onb-banner-in": "Onb-Motion",
|
||||
"onb-mark-in": "Onb-Motion", "onb-rise": "Onb-Motion", "onb-breathe": "Onb-Motion", "onb-grid": "Onb-Motion", "onb-banner-in": "Onb-Motion", "onb-dash": "Onb-Motion", "onb-land": "Onb-Motion", "onb-leave": "Onb-Motion", "compose-rise": "Onb-Motion",
|
||||
"onb-step-in": "Onb-Motion", "onb-step-back": "Onb-Motion", "onb-tile": "Onb-Motion", "onb-shake": "Onb-Motion",
|
||||
oobebrand: "Onb-Flow", oobecollapse: "Onb-Flow", oobefill: "Onb-Flow", oobegone: "Onb-Flow", oobelume: "Onb-Flow", oobeout: "Onb-Flow", oobesay: "Onb-Flow", oobeshrink: "Onb-Flow",
|
||||
"plan-spin-stem": "Chat-Plan", "plan-spin-bowl": "Chat-Plan", "plan-spin-leg": "Chat-Plan",
|
||||
|
||||
@@ -531,7 +531,13 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
|
||||
const branchSession = useBranchSession({ panePorts, runtimes, runsRef, openPane });
|
||||
|
||||
const first = useFirstRun({ port: activePort, setup, welcomed, setSetup, setWelcomed, reloadPanes });
|
||||
const first = useFirstRun({
|
||||
port: activePort, setup, welcomed, setSetup, setWelcomed, reloadPanes,
|
||||
project: {
|
||||
pick: async () => (await hub.pickFolder()) || null,
|
||||
add: async (dir) => { const workspace = await hub.addWorkspace(dir); await reloadTree(); await openPane({ root: workspace.root }); },
|
||||
},
|
||||
});
|
||||
|
||||
if (setup === undefined || welcomed === undefined) return <div className="app" data-run="idle" />;
|
||||
if (first.gate) return first.gate;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { CompletionMenu, useCompletion } from "./Completion";
|
||||
import { ChipMirror, useSkillChips } from "./ChipMirror";
|
||||
import { useIme } from "./ime";
|
||||
import { usePlaceholderFit } from "./placeholderfit";
|
||||
import { useTypedPlaceholder } from "./firstRise";
|
||||
import { useFitHeight } from "./fitHeight";
|
||||
import { onComposerHintChange, showsComposerHint, useRunningMessageIntent, useSendShortcut } from "../state/prefs";
|
||||
import { runningSendIntent, sendAria, sendHint, sendsOnKey } from "./sendkey";
|
||||
@@ -220,7 +221,8 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
useFitHeight(box, text, placeCaret);
|
||||
const showHint = useSyncExternalStore(onComposerHintChange, showsComposerHint, showsComposerHint);
|
||||
const fittedPlaceholder = usePlaceholderFit(box, queued ? [t("composer.keep_typing_to_queue_further"), t("composer.keep_typing"), "…"] : running ? [t("composer.you_can_keep_typing_while"), t("composer.keep_typing"), "…"] : [t("composer.describe_a_task_question_or"), t("composer.describe_a_task"), "…"]);
|
||||
const placeholder = showHint ? fittedPlaceholder : "";
|
||||
const typedPlaceholder = useTypedPlaceholder(fittedPlaceholder, text.length > 0);
|
||||
const placeholder = showHint ? typedPlaceholder : "";
|
||||
|
||||
// Attachments ride into the turn as path references, exactly as they do from
|
||||
// the CLI — the host saved the bytes, the turn parser resolves the token. A
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { MockPort } from "../port/mock";
|
||||
@@ -35,7 +35,7 @@ describe("first-run setup", () => {
|
||||
await user.click(screen.getByRole("radio", { name: /deepseek-flash/ }));
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
expect(save).toHaveBeenCalledWith(expect.objectContaining({ kind: "openai", baseUrl: "https://api.deepseek.com", default: "deepseek-flash", replace: true }));
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("asks a typed address how it is spoken to when it answers both ways, and saves the one chosen", async () => {
|
||||
@@ -64,3 +64,32 @@ describe("first-run setup", () => {
|
||||
expect(onSkip).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the project step", () => {
|
||||
function drawWithProject() {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
port.saveProvider = vi.fn(async () => {});
|
||||
const onDone = vi.fn();
|
||||
const add = vi.fn(async (_dir: string) => {});
|
||||
const pick = vi.fn(async () => "/work/demo");
|
||||
render(<Onboarding port={port} setup={{ required: true, provider: "", model: "" }} onDone={onDone} project={{ pick, add }} />);
|
||||
return { onDone, add, pick, user: userEvent.setup() };
|
||||
}
|
||||
|
||||
it("asks which folder to work in after the model, and starts only once one is chosen", async () => {
|
||||
const { onDone, add, user } = drawWithProject();
|
||||
await user.click(screen.getByRole("button", { name: "开始设置" }));
|
||||
await user.type(screen.getByPlaceholderText("sk-…"), "sk-test");
|
||||
await user.click(screen.getByRole("button", { name: "连接" }));
|
||||
await user.click(await screen.findByRole("button", { name: "继续" }, { timeout: 5000 }));
|
||||
expect(await screen.findByRole("heading", { name: "在哪个项目里干活?" })).toBeTruthy();
|
||||
const start = screen.getByRole("button", { name: "开始" }) as HTMLButtonElement;
|
||||
expect(start.disabled).toBe(true);
|
||||
await user.click(screen.getByRole("button", { name: "选择文件夹…" }));
|
||||
expect(await screen.findByText("demo")).toBeTruthy();
|
||||
expect(start.disabled).toBe(false);
|
||||
await user.click(start);
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith("/work/demo"));
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import { KIND_LABEL, nameFrom } from "./vendors";
|
||||
import { hasVendorMark, VendorMark } from "./vendormarks";
|
||||
import { OnboardingDemo } from "./OnboardingDemo";
|
||||
import { RMark } from "./RMark";
|
||||
import { OnboardingProject, type ProjectChoice } from "./OnboardingProject";
|
||||
import { startRise } from "./firstRise";
|
||||
import { Picker } from "./Menu";
|
||||
import { WindowControls, zoomOnTitleBar } from "./WindowControls";
|
||||
|
||||
@@ -16,6 +18,8 @@ interface Props {
|
||||
setup: ProviderSetup;
|
||||
onDone: () => void;
|
||||
onSkip?: () => void;
|
||||
// Which folder the work happens in. Without it setup ends at the model.
|
||||
project?: ProjectChoice;
|
||||
}
|
||||
|
||||
// Shortcuts fill the address, nothing more. A service missing from this row is
|
||||
@@ -53,7 +57,7 @@ const SHORTCUTS: { label: string; hint: string; url: string; mark?: string; cons
|
||||
// address the person typed is ever asked how it is spoken to.
|
||||
const MODEL_LIST_MAX = 8;
|
||||
|
||||
type Step = "welcome" | "connect" | "model";
|
||||
type Step = "welcome" | "connect" | "model" | "project";
|
||||
|
||||
// The two failures nobody fixes in this window: the key is refused, or the
|
||||
// account behind it is out of credit. Both are settled on the vendor's site,
|
||||
@@ -81,13 +85,14 @@ function failed(e: unknown): Failed {
|
||||
// it belongs to is the user's business, so this asks where and with what, then
|
||||
// lets the endpoint answer the rest — protocol, model list, image support are
|
||||
// all knowable by asking, and a person cannot be expected to know them.
|
||||
export function Onboarding({ port, setup, onDone, onSkip }: Props) {
|
||||
export function Onboarding({ port, setup, onDone, onSkip, project }: Props) {
|
||||
const [pick, setPick] = useState(SHORTCUTS[0].label);
|
||||
const [url, setUrl] = useState(SHORTCUTS[0].url);
|
||||
const [completed, setCompleted] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const [err, setErr] = useState<Failed | null>(setup.error ? { text: setup.error } : null);
|
||||
const [found, setFound] = useState<ProviderProbe | null>(null);
|
||||
const [catalog, setCatalog] = useState<Protocol[]>([]);
|
||||
@@ -97,6 +102,13 @@ export function Onboarding({ port, setup, onDone, onSkip }: Props) {
|
||||
const [step, setStep] = useState<Step>("welcome");
|
||||
const [back, setBack] = useState(false);
|
||||
const go = (next: Step, backwards = false) => { setBack(backwards); setStep(next); };
|
||||
// The card shrinks and fades before the conversation takes over, and the
|
||||
// composer rises into the place it left.
|
||||
const leave = (then: () => void) => {
|
||||
setLeaving(true);
|
||||
startRise();
|
||||
window.setTimeout(then, matchMedia?.("(prefers-reduced-motion: reduce)").matches ? 0 : 240);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void port.protocols().then(setCatalog).catch(() => setCatalog([]));
|
||||
@@ -167,7 +179,10 @@ export function Onboarding({ port, setup, onDone, onSkip }: Props) {
|
||||
});
|
||||
await port.setModel(`${name}/${model}`);
|
||||
await port.setDefaultModel(`${name}/${model}`);
|
||||
onDone();
|
||||
if (project) {
|
||||
setBusy(false);
|
||||
go("project");
|
||||
} else leave(onDone);
|
||||
} catch (e) {
|
||||
setErr(failed(e));
|
||||
setBusy(false);
|
||||
@@ -193,7 +208,7 @@ export function Onboarding({ port, setup, onDone, onSkip }: Props) {
|
||||
const at = steps.findIndex(([id]) => id === step);
|
||||
|
||||
return (
|
||||
<div className="onb-stage">
|
||||
<div className="onb-stage" data-leaving={leaving ? "" : undefined}>
|
||||
<header className="onb-brandbar" onDoubleClick={zoomOnTitleBar}>
|
||||
<span className="onb-lockup">
|
||||
<img className="onb-wordmark" src={logoWordmark} alt="Reasonix Studio" draggable={false} />
|
||||
@@ -300,6 +315,10 @@ export function Onboarding({ port, setup, onDone, onSkip }: Props) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "project" && project && (
|
||||
<OnboardingProject project={project} onDone={() => leave(onDone)} onSkip={() => leave(onDone)} />
|
||||
)}
|
||||
|
||||
{step === "model" && found && (
|
||||
<>
|
||||
<div className="onb-found" role="status">
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { useFileDrop } from "./filedrop";
|
||||
|
||||
export interface ProjectChoice {
|
||||
pick: () => Promise<string | null>;
|
||||
add: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const nameOf = (path: string) => path.replace(/[/\\]+$/, "").split(/[/\\]/).pop() || path;
|
||||
|
||||
// The last step: which folder the work happens in. A folder can be dropped on
|
||||
// the box or picked; once chosen it sits in the box and "Start" lights up.
|
||||
export function OnboardingProject({ project, onDone, onSkip }: { project: ProjectChoice; onDone: () => void; onSkip: () => void }) {
|
||||
const [over, setOver] = useState(false);
|
||||
const [dir, setDir] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const drop = useFileDrop((d) => { if (!busy && d.paths[0]) { setDir(d.paths[0]); setErr(""); } }, setOver);
|
||||
const pick = async () => {
|
||||
if (busy) return;
|
||||
try {
|
||||
const got = await project.pick();
|
||||
if (got) { setDir(got); setErr(""); }
|
||||
} catch (e) {
|
||||
setErr(reason(e));
|
||||
}
|
||||
};
|
||||
const start = async () => {
|
||||
if (!dir || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await project.add(dir);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setErr(reason(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<h1 className="onb-t">{t("onboarding.which_project")}</h1>
|
||||
<p className="onb-s">{t("onboarding.drop_to_choose")}</p>
|
||||
<div className="onb-drop" ref={drop} data-over={over ? "" : undefined} data-chosen={dir ? "" : undefined}>
|
||||
{dir ? (
|
||||
<div className="onb-chosen">
|
||||
<StudioIcon name="folder" />
|
||||
<b>{nameOf(dir)}</b>
|
||||
<small title={dir}>{dir}</small>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<StudioIcon name="folder" />
|
||||
<span>{t("onboarding.drop_a_folder_here")}</span>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="onb-skip" data-action="onboarding.pick-folder" disabled={busy} onClick={() => void pick()}>
|
||||
{t(dir ? "onboarding.choose_another" : "onboarding.choose_a_folder")}
|
||||
</button>
|
||||
</div>
|
||||
{err && <div className="onb-err" role="alert">{err}</div>}
|
||||
<div className="onb-acts">
|
||||
<button className="btn onb-go" data-primary data-action="onboarding.start" disabled={!dir || busy} onClick={() => void start()}>{t("common.start")}</button>
|
||||
<button type="button" className="onb-skip" data-action="onboarding.skip" disabled={busy} onClick={onSkip}>{t("onboarding.not_now")}</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { RunLine } from "./RunLine";
|
||||
import { AgentCompositionPicker } from "./AgentCompositionPicker";
|
||||
import type { PaneProps, PaneReport } from "./panetypes";
|
||||
import { TerminalHostContext } from "./terminal_host";
|
||||
import { isRising } from "./firstRise";
|
||||
import { useRate, useTrail } from "./num";
|
||||
import { useCompactNow } from "./CompactNow";
|
||||
import { DOCK, Gutter } from "./Gutter";
|
||||
@@ -626,7 +627,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
{/* Keep the composer mounted so a half-written prompt survives a visit to
|
||||
analysis; hidden removes it from layout without throwing its state
|
||||
away. */}
|
||||
<div className="compose" hidden={tab !== "flow"}>
|
||||
<div className="compose" hidden={tab !== "flow"} data-rise={isRising() ? "" : undefined}>
|
||||
<span className="glowring" aria-hidden="true">
|
||||
<i />
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const WINDOW_MS = 2500;
|
||||
let until = 0;
|
||||
|
||||
// Set when first-run setup hands over to the conversation: for a moment after,
|
||||
// the composer rises into place and types its prompt, once.
|
||||
export const startRise = () => { until = Date.now() + WINDOW_MS; };
|
||||
export const isRising = () => Date.now() < until;
|
||||
|
||||
const calm = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
// The text typed out over 1.2 seconds right after setup, and whole at any other
|
||||
// time. Typing into the box, or reduced motion, shows it whole at once.
|
||||
export function useTypedPlaceholder(full: string, typing: boolean): string {
|
||||
const [active] = useState(() => isRising() && !calm());
|
||||
const [shown, setShown] = useState(active ? 0 : full.length);
|
||||
useEffect(() => {
|
||||
if (!active || typing) return;
|
||||
const step = Math.max(20, 1200 / Math.max(1, full.length));
|
||||
const timer = window.setInterval(() => setShown((n) => (n >= full.length ? n : n + 1)), step);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active, typing, full]);
|
||||
return !active || typing || shown >= full.length ? full : full.slice(0, shown);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
|
||||
import type { AgentPort, ProviderSetup } from "../port/port";
|
||||
import type { ConnectOffer } from "./ConnectNote";
|
||||
import { Onboarding } from "./Onboarding";
|
||||
import type { ProjectChoice } from "./OnboardingProject";
|
||||
|
||||
interface Args {
|
||||
port: AgentPort | null | undefined;
|
||||
@@ -10,12 +11,13 @@ interface Args {
|
||||
setSetup: (next: ProviderSetup | null) => void;
|
||||
setWelcomed: (next: boolean) => void;
|
||||
reloadPanes: () => Promise<unknown> | void;
|
||||
project?: ProjectChoice;
|
||||
}
|
||||
|
||||
// The key prompt is owed until a provider exists, but putting it off is the
|
||||
// person's call for this launch only: the deferral lives in memory, so the next
|
||||
// start asks again.
|
||||
export function useFirstRun({ port, setup, welcomed, setSetup, setWelcomed, reloadPanes }: Args): { gate: ReactNode; connect?: ConnectOffer } {
|
||||
export function useFirstRun({ port, setup, welcomed, setSetup, setWelcomed, reloadPanes, project }: Args): { gate: ReactNode; connect?: ConnectOffer } {
|
||||
const [deferred, setDeferred] = useState(false);
|
||||
if (!setup?.required || welcomed === undefined || !port) return { gate: null };
|
||||
if (deferred) return { gate: null, connect: { none: !!setup.noProvider, open: () => setDeferred(false) } };
|
||||
@@ -30,6 +32,7 @@ export function useFirstRun({ port, setup, welcomed, setSetup, setWelcomed, relo
|
||||
<Onboarding
|
||||
port={port}
|
||||
setup={setup}
|
||||
project={project}
|
||||
onSkip={() => {
|
||||
setDeferred(true);
|
||||
settleWelcome();
|
||||
|
||||
Reference in new issue
Block a user