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:
yhh committed 2026-11-06 07:26:01 -08:00
1 parent c4375bcde9
commit c457700233
12 files changed
+190 -11

No files matched your search

+2
View File
@@ -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",
+7 -1
View File
@@ -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;
+3 -1
View File
@@ -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());
});
});
+23 -4
View File
@@ -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>
</>
);
}
+2 -1
View File
@@ -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>
+25
View File
@@ -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);
}
+4 -1
View File
@@ -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();