mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-10-02 01:34:34 +08:00
Add lightweight project briefs and refresh onboarding (#200)
* feat: add lightweight project briefs and onboarding refresh * fix: refine onboarding and project workspace interactions * fix: harden demo onboarding state * fix: satisfy current clippy lint * chore: bump version to 0.1.105
This commit is contained in:
Generated
+1
-1
@@ -1025,7 +1025,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openresearch-cli"
|
||||
version = "0.1.104"
|
||||
version = "0.1.105"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openresearch-cli"
|
||||
version = "0.1.104"
|
||||
version = "0.1.105"
|
||||
edition = "2021"
|
||||
description = "OpenResearch CLI (orx) — Rust port"
|
||||
repository = "https://github.com/alphaXiv/openresearch-cli"
|
||||
|
||||
@@ -37,6 +37,31 @@ only the commands listed below exist; use this project id (`{id}`) for every
|
||||
|
||||
Orient with `orx projects` and `orx runs {id}`.
|
||||
|
||||
## PROJECT.md describes the project; the user directs it
|
||||
|
||||
The project's `PROJECT.md` is a concise, user-facing snapshot, not instructions
|
||||
for you and not a work queue. For project direction, the user's latest request
|
||||
and actions have the highest priority, followed by the active conversation and
|
||||
the observed state of the code, experiments, and artifacts. `PROJECT.md` comes
|
||||
last and is descriptive, never prescriptive.
|
||||
|
||||
Never refuse, delay, redirect, or ask for confirmation solely because
|
||||
`PROJECT.md` disagrees with the user. Execute the user's current request, then
|
||||
update the brief to reflect what the user now wants, what was implemented, and
|
||||
the resulting project state. Do not read the brief at session start, before
|
||||
planning, or to decide what to do. Read it only after current work has made an
|
||||
update appropriate, so you can preserve its existing contents while editing.
|
||||
|
||||
Update it when the user changes the objective or direction, completed work
|
||||
materially changes the summary, a validated finding belongs in the highlights,
|
||||
or a concrete future experiment is worth remembering. Do not update it for
|
||||
routine run completion, temporary blockers, incidental details, or merely
|
||||
because a turn is ending. Keep future experiments nonbinding; keep the whole
|
||||
brief concise; distinguish findings from hypotheses; link useful supporting
|
||||
experiments or artifacts; and exclude transcripts, raw logs, and secrets.
|
||||
Use `orx project brief show {id}` to read it immediately before an update, then
|
||||
rewrite it with `orx project brief update {id} --stdin`.
|
||||
|
||||
## Skills
|
||||
|
||||
Focused how-to guides are installed as **native skills for this session** — your
|
||||
@@ -130,6 +155,7 @@ preferences.
|
||||
| `orx projects` | List projects; local ones are tagged `(local)`. |
|
||||
| `orx create-experiment {id} --title "<t>" [--description "<d>"] [--parent <expId> \| --baseline] [--run-command "<cmd>"]` | New node on its own `orx/<slug>` branch, {experiment_publish_clause} — forked off the parent's tip, or off `{baseline}` for a root. Omit `--parent` to attach under the oldest root (or become the baseline on an empty project). |
|
||||
| `orx project view {id}` / `orx project edit {id} --run-command "<cmd>"` | Inspect the project / set its default run command. |
|
||||
| `orx project brief show {id}` / `orx project brief update {id} --stdin` | Read or replace the user-facing project snapshot. It never overrides a current user request. |
|
||||
| `orx exp status <expId>` | Node's branch, command, and latest run. |
|
||||
| `orx exp desc <expId> [--set "<text>" \| --stdin]` | Read/overwrite the node's notes. Record findings here. |
|
||||
| {run_invocation} | {run_guidance} |
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# nanochat demo evidence
|
||||
|
||||
This compact evidence pack comes from the completed Apple-Silicon run recorded in the demo. It preserves the small outputs needed to inspect the experiment without bundling the multi-gigabyte training workspace.
|
||||
|
||||
## Included
|
||||
|
||||
- `training-metrics.csv` — base and SFT loss, validation BPB, throughput, and elapsed time by step
|
||||
- `evaluation-metrics.json` — base BPB, completed CORE task results, and final headline metrics
|
||||
- `checkpoints/base/meta_005000.json` — metadata saved with the final base checkpoint
|
||||
- `checkpoints/sft/meta_001499.json` — metadata saved with the final SFT checkpoint
|
||||
- `tokenizer/tokenizer.pkl` and `tokenizer/token_bytes.pt` — the tokenizer produced by this run
|
||||
- `final-inference.txt` — the recorded prompt and response from the final SFT checkpoint
|
||||
- `run-manifest.json` — an inventory of included and omitted run outputs
|
||||
|
||||
`training-metrics.csv` and `evaluation-metrics.json` are regenerated from the recorded run log with `node scripts/generate-demo-evidence.mjs`. The checkpoint metadata, tokenizer files, inference transcript, and manifest are preserved directly from the recorded run. Because `tokenizer.pkl` uses Python's pickle format, load it only through nanochat's trusted tokenizer code.
|
||||
|
||||
## Intentionally omitted
|
||||
|
||||
The model weights, optimizer states, downloaded datasets, evaluation corpus, Python environment, and package cache are not bundled. They account for several gigabytes and normally remain in the run-local workspace rather than the Git repository or project artifacts.
|
||||
|
||||
The manifest records the exact checkpoint paths, byte sizes, and SHA-256 hashes so follow-up analysis can distinguish files that were produced from files that are locally available. Run `bash runs/runcpu.sh` in a fresh experiment to reproduce the complete workspace.
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"step": 5000,
|
||||
"val_bpb": 1.1657578772056119,
|
||||
"model_config": {
|
||||
"sequence_len": 512,
|
||||
"vocab_size": 32768,
|
||||
"n_layer": 6,
|
||||
"n_head": 6,
|
||||
"n_kv_head": 6,
|
||||
"n_embd": 384,
|
||||
"window_pattern": "L"
|
||||
},
|
||||
"user_config": {
|
||||
"run": "dummy",
|
||||
"device_type": "",
|
||||
"fp8": false,
|
||||
"fp8_recipe": "tensorwise",
|
||||
"depth": 6,
|
||||
"aspect_ratio": 64,
|
||||
"head_dim": 64,
|
||||
"max_seq_len": 512,
|
||||
"window_pattern": "L",
|
||||
"num_iterations": 5000,
|
||||
"target_flops": -1.0,
|
||||
"target_param_data_ratio": 12,
|
||||
"device_batch_size": 32,
|
||||
"total_batch_size": 16384,
|
||||
"embedding_lr": 0.3,
|
||||
"unembedding_lr": 0.008,
|
||||
"weight_decay": 0.28,
|
||||
"matrix_lr": 0.02,
|
||||
"scalar_lr": 0.5,
|
||||
"warmup_steps": 40,
|
||||
"warmdown_ratio": 0.65,
|
||||
"final_lr_frac": 0.05,
|
||||
"resume_from_step": -1,
|
||||
"eval_every": 100,
|
||||
"eval_tokens": 524288,
|
||||
"core_metric_every": -1,
|
||||
"core_metric_max_per_task": 500,
|
||||
"sample_every": 100,
|
||||
"save_every": -1,
|
||||
"model_tag": null
|
||||
},
|
||||
"device_batch_size": 32,
|
||||
"max_seq_len": 512,
|
||||
"total_batch_size": 16384,
|
||||
"dataloader_state_dict": {
|
||||
"pq_idx": 2,
|
||||
"rg_idx": 48,
|
||||
"epoch": 1
|
||||
},
|
||||
"loop_state": {
|
||||
"min_val_bpb": 1.1657578772056119,
|
||||
"smooth_train_loss": 3.7514667110277076,
|
||||
"total_training_time": 7893.223114013672
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"step": 1499,
|
||||
"val_bpb": 0.7388738131275181,
|
||||
"model_config": {
|
||||
"sequence_len": 512,
|
||||
"vocab_size": 32768,
|
||||
"n_layer": 6,
|
||||
"n_head": 6,
|
||||
"n_kv_head": 6,
|
||||
"n_embd": 384,
|
||||
"window_pattern": "L"
|
||||
},
|
||||
"user_config": {
|
||||
"run": "dummy",
|
||||
"device_type": "",
|
||||
"model_tag": null,
|
||||
"model_step": null,
|
||||
"load_optimizer": 0,
|
||||
"num_iterations": 1500,
|
||||
"max_seq_len": null,
|
||||
"device_batch_size": null,
|
||||
"total_batch_size": null,
|
||||
"embedding_lr": null,
|
||||
"unembedding_lr": null,
|
||||
"matrix_lr": null,
|
||||
"init_lr_frac": 0.8,
|
||||
"warmup_ratio": 0.0,
|
||||
"warmdown_ratio": 0.5,
|
||||
"final_lr_frac": 0.0,
|
||||
"eval_every": 200,
|
||||
"eval_tokens": 524288,
|
||||
"chatcore_every": -1,
|
||||
"chatcore_max_cat": -1,
|
||||
"chatcore_max_sample": 24,
|
||||
"mmlu_epochs": 3,
|
||||
"gsm8k_epochs": 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"baseEvaluation": {
|
||||
"trainBpb": 1.152185,
|
||||
"validationBpb": 1.119301
|
||||
},
|
||||
"core": [
|
||||
{
|
||||
"task": "bigbench_qa_wikidata",
|
||||
"accuracy": 0,
|
||||
"centered": 0,
|
||||
"seconds": 0.82
|
||||
},
|
||||
{
|
||||
"task": "openbook_qa",
|
||||
"accuracy": 0.25,
|
||||
"centered": 0,
|
||||
"seconds": 1
|
||||
},
|
||||
{
|
||||
"task": "winogrande",
|
||||
"accuracy": 0.5625,
|
||||
"centered": 0.125,
|
||||
"seconds": 0.79
|
||||
},
|
||||
{
|
||||
"task": "bigbench_operators",
|
||||
"accuracy": 0,
|
||||
"centered": 0,
|
||||
"seconds": 1.2
|
||||
}
|
||||
],
|
||||
"final": {
|
||||
"baseValidationBpb": 1.165758,
|
||||
"sftValidationBpb": 0.7389,
|
||||
"chatAnswer": "Paris"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
nanochat CPU / Apple-Silicon final inference
|
||||
|
||||
Command: python -m scripts.chat_cli -p "What is the capital of France?"
|
||||
Checkpoint: chatsft_checkpoints/d6/model_001499.pt
|
||||
Checkpoint step: 1499
|
||||
Device: mps
|
||||
|
||||
Prompt:
|
||||
What is the capital of France?
|
||||
|
||||
Response:
|
||||
Paris
|
||||
Paris is a city known for its historical and cultural significance. The capital of France is Paris, a 1715,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,345,
|
||||
|
||||
Run status: completed successfully
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"run": {
|
||||
"command": "bash runs/runcpu.sh && python -m scripts.chat_cli -p \"What is the capital of France?\"",
|
||||
"device": "mps",
|
||||
"status": "completed",
|
||||
"baseDirectory": "$ORX_RUN_DIR/repo/.cache/nanochat"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"path": "base_checkpoints/d6/meta_005000.json",
|
||||
"kind": "checkpoint_metadata",
|
||||
"bytes": 1334,
|
||||
"sha256": "d06eaa2610b3d415b79dbdbe0e6ae7c6378e5f0e4234b3639d0479bcf4083516",
|
||||
"bundledAt": "evidence/checkpoints/base/meta_005000.json"
|
||||
},
|
||||
{
|
||||
"path": "base_checkpoints/d6/model_005000.pt",
|
||||
"kind": "model_checkpoint",
|
||||
"bytes": 294145379,
|
||||
"sha256": "a334c64d8b4a16f087e179072092f5d1b6c94a55e279bc2fdab9d08655f7fda2",
|
||||
"bundledAt": null
|
||||
},
|
||||
{
|
||||
"path": "base_checkpoints/d6/optim_005000_rank0.pt",
|
||||
"kind": "optimizer_checkpoint",
|
||||
"bytes": 545905413,
|
||||
"sha256": "6cff47493cf87deaa4d107521bef081a2e8a7ec965d9fbfd50310152905d8d8e",
|
||||
"bundledAt": null
|
||||
},
|
||||
{
|
||||
"path": "chatsft_checkpoints/d6/meta_001499.json",
|
||||
"kind": "checkpoint_metadata",
|
||||
"bytes": 838,
|
||||
"sha256": "0c94c4707e926e07b9d67e98548280aed2eb5fb9f81d279ed218a6186ff1c7fa",
|
||||
"bundledAt": "evidence/checkpoints/sft/meta_001499.json"
|
||||
},
|
||||
{
|
||||
"path": "chatsft_checkpoints/d6/model_001499.pt",
|
||||
"kind": "model_checkpoint",
|
||||
"bytes": 294145379,
|
||||
"sha256": "96570ecb334bc9fd392d5b84c920859e8e69e52071a3357b5293ad45fe7d39f1",
|
||||
"bundledAt": null
|
||||
},
|
||||
{
|
||||
"path": "chatsft_checkpoints/d6/optim_001499_rank0.pt",
|
||||
"kind": "optimizer_checkpoint",
|
||||
"bytes": 545905413,
|
||||
"sha256": "697349059277d209fc6b7cb78ff615651c5a0aed2e74916117d68974d2522834",
|
||||
"bundledAt": null
|
||||
},
|
||||
{
|
||||
"path": "tokenizer/token_bytes.pt",
|
||||
"kind": "tokenizer",
|
||||
"bytes": 132649,
|
||||
"sha256": "409e71bd5a03d0974001eae103c9e880097b972668bbd9b646856c63773b0baf",
|
||||
"bundledAt": "evidence/tokenizer/token_bytes.pt"
|
||||
},
|
||||
{
|
||||
"path": "tokenizer/tokenizer.pkl",
|
||||
"kind": "tokenizer",
|
||||
"bytes": 412105,
|
||||
"sha256": "387cfc082b0bee45467774fd6f1310a922ad170886a58ccddcb468f275e06a6c",
|
||||
"bundledAt": "evidence/tokenizer/tokenizer.pkl"
|
||||
}
|
||||
],
|
||||
"omittedDirectories": [
|
||||
{
|
||||
"path": "base_data_climbmix",
|
||||
"files": 9,
|
||||
"bytes": 825645585
|
||||
},
|
||||
{
|
||||
"path": "task_data",
|
||||
"files": 24,
|
||||
"bytes": 1024743258
|
||||
},
|
||||
{
|
||||
"path": "eval_bundle",
|
||||
"files": 77,
|
||||
"bytes": 168977052
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const log = readFileSync(join(root, "demo/nanochat/run-output.txt"), "utf8");
|
||||
const output = join(root, "demo/nanochat/evidence");
|
||||
mkdirSync(output, { recursive: true });
|
||||
|
||||
const rows = new Map();
|
||||
let phase = "base";
|
||||
let trainBpb;
|
||||
let validationBpb;
|
||||
const core = [];
|
||||
|
||||
function rowFor(currentPhase, step) {
|
||||
const key = `${currentPhase}:${step}`;
|
||||
const existing = rows.get(key);
|
||||
if (existing) return existing;
|
||||
const row = {
|
||||
phase: currentPhase,
|
||||
step,
|
||||
loss: "",
|
||||
validationBpb: "",
|
||||
tokensPerSecond: "",
|
||||
totalMinutes: "",
|
||||
};
|
||||
rows.set(key, row);
|
||||
return row;
|
||||
}
|
||||
|
||||
for (const line of log.split("\n")) {
|
||||
if (line === "=== Supervised fine-tuning ===") phase = "sft";
|
||||
|
||||
const training = line.match(
|
||||
/^step (\d+)(?:\/\d+)? .*?\| loss: ([\d.]+).*?\| tok\/sec: ([\d,]+).*?\| total time: ([\d.]+)m/,
|
||||
);
|
||||
if (training) {
|
||||
const row = rowFor(phase, Number(training[1]));
|
||||
row.loss = training[2];
|
||||
row.tokensPerSecond = training[3].replaceAll(",", "");
|
||||
row.totalMinutes = training[4];
|
||||
}
|
||||
|
||||
const validation = line.match(/^Step (\d+) \| Validation bpb: ([\d.]+)/);
|
||||
if (validation) rowFor(phase, Number(validation[1])).validationBpb = validation[2];
|
||||
|
||||
if (phase === "base") {
|
||||
const train = line.match(/^train bpb: ([\d.]+)/);
|
||||
if (train) trainBpb = Number(train[1]);
|
||||
const val = line.match(/^val bpb: ([\d.]+)/);
|
||||
if (val) validationBpb = Number(val[1]);
|
||||
const evaluation = line.match(
|
||||
/^Evaluating: ([^ ]+).*?accuracy: ([\d.]+) \| centered: ([\d.]+) \| time: ([\d.]+)s/,
|
||||
);
|
||||
if (evaluation) {
|
||||
core.push({
|
||||
task: evaluation[1],
|
||||
accuracy: Number(evaluation[2]),
|
||||
centered: Number(evaluation[3]),
|
||||
seconds: Number(evaluation[4]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderedRows = [...rows.values()].sort((left, right) => {
|
||||
if (left.phase !== right.phase) return left.phase === "base" ? -1 : 1;
|
||||
return left.step - right.step;
|
||||
});
|
||||
const finalValidationBpb = (currentPhase) =>
|
||||
Number(
|
||||
orderedRows.findLast(
|
||||
(row) => row.phase === currentPhase && row.validationBpb !== "",
|
||||
).validationBpb,
|
||||
);
|
||||
const csv = [
|
||||
"phase,step,loss,validation_bpb,tokens_per_second,total_minutes",
|
||||
...orderedRows.map((row) =>
|
||||
[
|
||||
row.phase,
|
||||
row.step,
|
||||
row.loss,
|
||||
row.validationBpb,
|
||||
row.tokensPerSecond,
|
||||
row.totalMinutes,
|
||||
].join(","),
|
||||
),
|
||||
].join("\n");
|
||||
writeFileSync(join(output, "training-metrics.csv"), `${csv}\n`);
|
||||
|
||||
const evaluation = {
|
||||
baseEvaluation: {
|
||||
trainBpb,
|
||||
validationBpb,
|
||||
},
|
||||
core,
|
||||
final: {
|
||||
baseValidationBpb: finalValidationBpb("base"),
|
||||
sftValidationBpb: finalValidationBpb("sft"),
|
||||
chatAnswer: "Paris",
|
||||
},
|
||||
};
|
||||
writeFileSync(join(output, "evaluation-metrics.json"), `${JSON.stringify(evaluation, null, 2)}\n`);
|
||||
+46
-9
@@ -6,28 +6,65 @@
|
||||
//! one — mirroring `orx experiments` (list) vs `orx exp` (operate). Project ids
|
||||
//! come from `orx projects`.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::error::{anyhow, Result};
|
||||
use crate::local::resolve::ProjectRef;
|
||||
use crate::plane::{resolve_project, ProjectEdit};
|
||||
use crate::ProjectCommand;
|
||||
use crate::{ProjectBriefCommand, ProjectCommand};
|
||||
|
||||
pub async fn run(args: crate::ProjectArgs) -> Result<()> {
|
||||
let project_id = match &args.command {
|
||||
ProjectCommand::View { project_id } | ProjectCommand::Edit { project_id, .. } => project_id,
|
||||
async fn run_brief(command: ProjectBriefCommand) -> Result<()> {
|
||||
let project_id = match &command {
|
||||
ProjectBriefCommand::Show { project_id }
|
||||
| ProjectBriefCommand::Update { project_id, .. } => project_id,
|
||||
};
|
||||
let store = crate::store::Store::open()?;
|
||||
let plane = resolve_project(store, project_id)?;
|
||||
let project = match crate::local::resolve::resolve_project(&store, project_id)? {
|
||||
ProjectRef::Local(project) => project,
|
||||
ProjectRef::Server(_) => {
|
||||
return Err(anyhow!(
|
||||
"`orx project brief` is available only for local OpenResearch projects"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
match command {
|
||||
ProjectBriefCommand::Show { .. } => {
|
||||
print!("{}", crate::local::files::read_project_brief(&project)?);
|
||||
Ok(())
|
||||
}
|
||||
ProjectBriefCommand::Update { .. } => {
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
let mut content = String::new();
|
||||
tokio::io::stdin().read_to_string(&mut content).await?;
|
||||
if content.len() > crate::local::files::MAX_PROJECT_BRIEF_BYTES {
|
||||
return Err(anyhow!(
|
||||
"PROJECT.md is too large; keep the project brief under 256 KiB"
|
||||
));
|
||||
}
|
||||
crate::local::files::write_project_brief(&project, &content)?;
|
||||
println!("✓ Updated PROJECT.md");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(args: crate::ProjectArgs) -> Result<()> {
|
||||
match args.command {
|
||||
ProjectCommand::View { .. } => plane.view_project().await,
|
||||
ProjectCommand::Brief { command } => run_brief(command).await,
|
||||
ProjectCommand::View { project_id } => {
|
||||
let store = crate::store::Store::open()?;
|
||||
resolve_project(store, &project_id)?.view_project().await
|
||||
}
|
||||
ProjectCommand::Edit {
|
||||
project_id,
|
||||
name,
|
||||
description,
|
||||
description_stdin,
|
||||
public,
|
||||
private,
|
||||
run_command,
|
||||
..
|
||||
} => {
|
||||
plane
|
||||
let store = crate::store::Store::open()?;
|
||||
resolve_project(store, &project_id)?
|
||||
.edit_project(ProjectEdit {
|
||||
name,
|
||||
description,
|
||||
|
||||
+50
-4
@@ -20,7 +20,7 @@ use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, Method, StatusCode, Uri};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::routing::{get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use base64::Engine as _;
|
||||
use futures::Stream;
|
||||
@@ -319,6 +319,12 @@ fn router(state: AppState) -> Router {
|
||||
get(list_artifacts).delete(delete_artifact),
|
||||
)
|
||||
.route("/api/projects/{id}/files/file", get(serve_artifact))
|
||||
.route(
|
||||
"/api/projects/{id}/brief",
|
||||
put(write_project_brief).layer(DefaultBodyLimit::max(
|
||||
local::files::MAX_PROJECT_BRIEF_BYTES * 6 + 1024,
|
||||
)),
|
||||
)
|
||||
.route("/api/events", get(events))
|
||||
.route("/api/settings/hf", get(hf_settings).post(set_hf_token))
|
||||
.route(
|
||||
@@ -984,9 +990,9 @@ async fn create_project(
|
||||
let shallow_clone = local::github::should_shallow_clone(repo_size_kb);
|
||||
let run_command = req.run_command;
|
||||
let creation_guard = state.project_creation_lock.lock().await;
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let result = tokio::task::spawn_blocking(move || -> Result<local::model::LocalProject> {
|
||||
let store = Store::open()?;
|
||||
local::projects::create_project(
|
||||
let project = local::projects::create_project(
|
||||
&store,
|
||||
&name,
|
||||
&path,
|
||||
@@ -998,7 +1004,12 @@ async fn create_project(
|
||||
run_command,
|
||||
paper_id,
|
||||
},
|
||||
)
|
||||
)?;
|
||||
if let Err(error) = local::files::ensure_project_brief(&project) {
|
||||
store.delete_local_project(&project.id)?;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(project)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("project task failed: {e}"))?;
|
||||
@@ -2463,6 +2474,11 @@ async fn delete_artifact(
|
||||
Query(q): Query<ArtifactPathQuery>,
|
||||
) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
if local::files::is_project_brief_path(&q.path) {
|
||||
return Err(bad_request(
|
||||
"PROJECT.md is part of the project and cannot be deleted",
|
||||
));
|
||||
}
|
||||
blocking_api(move || {
|
||||
let store = Store::open()?;
|
||||
let project = store
|
||||
@@ -2474,6 +2490,36 @@ async fn delete_artifact(
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProjectBriefWriteReq {
|
||||
content: String,
|
||||
}
|
||||
|
||||
async fn write_project_brief(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<ProjectBriefWriteReq>,
|
||||
) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
if req.content.len() > local::files::MAX_PROJECT_BRIEF_BYTES {
|
||||
return Err(bad_request(
|
||||
"PROJECT.md is too large; keep the project brief under 256 KiB",
|
||||
));
|
||||
}
|
||||
blocking_api(move || {
|
||||
let store = Store::open()?;
|
||||
let project = store
|
||||
.get_local_project(&id)?
|
||||
.ok_or_else(|| not_found("project"))?;
|
||||
local::files::write_project_brief(&project, &req.content)?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"bytesWritten": req.content.len(),
|
||||
})))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Raw artifact bytes, by directory-relative path. `no-cache`: the same path can
|
||||
/// be rewritten in place on disk.
|
||||
async fn serve_artifact(
|
||||
|
||||
+51
-11
@@ -1107,16 +1107,28 @@ fn is_initial_chat_message(transcript_text: Option<&str>, has_messages: bool) ->
|
||||
transcript_text.is_none() && !has_messages
|
||||
}
|
||||
|
||||
fn with_bootstrap_context(
|
||||
fn with_turn_context(
|
||||
native_session_id: Option<&str>,
|
||||
bootstrap_context: Option<&str>,
|
||||
demo_evidence_context: Option<&str>,
|
||||
text: String,
|
||||
) -> String {
|
||||
match (native_session_id, bootstrap_context) {
|
||||
(None, Some(context)) => {
|
||||
format!("{context}\n\n<current-user-message>\n{text}\n</current-user-message>")
|
||||
let mut contexts = Vec::new();
|
||||
if native_session_id.is_none() {
|
||||
if let Some(context) = bootstrap_context {
|
||||
contexts.push(context);
|
||||
}
|
||||
_ => text,
|
||||
}
|
||||
if let Some(context) = demo_evidence_context {
|
||||
contexts.push(context);
|
||||
}
|
||||
if contexts.is_empty() {
|
||||
text
|
||||
} else {
|
||||
format!(
|
||||
"{}\n\n<current-user-message>\n{text}\n</current-user-message>",
|
||||
contexts.join("\n\n")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1141,8 +1153,8 @@ fn with_selected_chat_context(text: String, annotations: &[TextAnnotation]) -> S
|
||||
#[cfg(test)]
|
||||
mod initial_message_tests {
|
||||
use super::{
|
||||
contextualize_messages, is_initial_chat_message, with_bootstrap_context,
|
||||
with_selected_chat_context, AnnotatedText, TextAnnotation,
|
||||
contextualize_messages, is_initial_chat_message, with_selected_chat_context,
|
||||
with_turn_context, AnnotatedText, TextAnnotation,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -1155,19 +1167,45 @@ mod initial_message_tests {
|
||||
|
||||
#[test]
|
||||
fn bootstrap_context_is_injected_only_before_a_native_session_exists() {
|
||||
let seeded = with_bootstrap_context(None, Some("prior demo"), "continue".into());
|
||||
let seeded = with_turn_context(None, Some("prior demo"), None, "continue".into());
|
||||
assert!(seeded.contains("prior demo"));
|
||||
assert!(seeded.contains("<current-user-message>\ncontinue"));
|
||||
assert_eq!(
|
||||
with_bootstrap_context(Some("native"), Some("prior demo"), "continue".into()),
|
||||
with_turn_context(Some("native"), Some("prior demo"), None, "continue".into()),
|
||||
"continue"
|
||||
);
|
||||
assert_eq!(
|
||||
with_bootstrap_context(None, None, "continue".into()),
|
||||
with_turn_context(None, None, None, "continue".into()),
|
||||
"continue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_evidence_context_is_injected_without_rewrapping_the_user_message() {
|
||||
let first = with_turn_context(
|
||||
None,
|
||||
Some("prior demo"),
|
||||
Some("demo evidence"),
|
||||
"first".into(),
|
||||
);
|
||||
let follow_up = with_turn_context(
|
||||
Some("native"),
|
||||
Some("prior demo"),
|
||||
Some("demo evidence"),
|
||||
"follow up".into(),
|
||||
);
|
||||
assert!(first.contains("demo evidence"));
|
||||
assert!(first.contains("prior demo"));
|
||||
assert!(first.contains("<current-user-message>\nfirst"));
|
||||
assert!(follow_up.contains("demo evidence"));
|
||||
assert!(follow_up.contains("<current-user-message>\nfollow up"));
|
||||
assert_eq!(first.matches("<current-user-message>").count(), 1);
|
||||
assert_eq!(
|
||||
with_turn_context(Some("native"), None, None, "ordinary".into()),
|
||||
"ordinary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_chat_context_wraps_the_harness_message() {
|
||||
let annotations = vec, structured metrics, checkpoint metadata, tokenizer, inference transcript, and run manifest
|
||||
"#;
|
||||
|
||||
const PROJECT_BRIEF: &str = r#"# Objective
|
||||
|
||||
Explore how OpenResearch organizes a realistic research project by training and evaluating Andrej Karpathy's [nanochat](https://github.com/karpathy/nanochat), a repository for training a mini-GPT from scratch, on the CPU/Apple-Silicon pipeline.
|
||||
|
||||
# Current Project Summary
|
||||
|
||||
The recorded `runs/runcpu.sh` pipeline completed successfully on Apple Silicon. A 6-layer, 73.5M-parameter model was pretrained for 5,000 steps, evaluated, supervised-fine-tuned for 1,500 steps, and confirmed through the chat CLI. The bundled evidence contains metrics, checkpoint metadata, the trained tokenizer, figures, and the final inference transcript. Model weights, optimizer states, datasets, and environments were intentionally omitted to keep the demo bundle small.
|
||||
|
||||
# Important Highlights
|
||||
|
||||
- Base training processed 81.92M tokens and ended at validation BPB 1.165758; the validation curve was still improving at the final checkpoint.
|
||||
- Base evaluation measured train BPB 1.152185 and validation BPB 1.119301.
|
||||
- SFT reached validation BPB 0.7389, and the final chat CLI answered that the capital of France is Paris.
|
||||
- The evidence-backed diagnosis is that insufficient pretraining is the primary bottleneck, with model size a secondary ceiling and SFT likely amplifying repetition.
|
||||
|
||||
# Future Experiments
|
||||
|
||||
- Keep the d6 model and recipe fixed while extending pretraining to nanochat's target token-to-parameter ratio of 12, evaluating checkpoints at 5,000, about 11,000, and 16,992 steps.
|
||||
- If longer pretraining improves the base model but repetition remains after SFT, compare SFT early stopping or a lower learning rate without changing the pretraining recipe.
|
||||
"#;
|
||||
|
||||
const REPORT: &str = r#"# nanochat CPU / Apple-Silicon pipeline results
|
||||
@@ -94,6 +122,10 @@ This bundled demo records a completed local `runs/runcpu.sh` pipeline on Apple S
|
||||
|
||||
The final command loaded the step-1499 SFT checkpoint on MPS and answered: “Paris … The capital of France is Paris.”
|
||||
|
||||
## Evidence pack
|
||||
|
||||
The `evidence/` directory contains the real checkpoint metadata, trained tokenizer, structured metrics, final inference transcript, and a manifest of both bundled and omitted run outputs. To reduce the bundled demo project's download size, large model weights, optimizer states, datasets, and environments are intentionally omitted.
|
||||
|
||||
## Portable setup repairs
|
||||
|
||||
- Kept nanochat and uv caches inside the checkout.
|
||||
@@ -116,6 +148,10 @@ struct ExperimentAssets;
|
||||
#[folder = "demo/nanochat/figures/"]
|
||||
struct FigureAssets;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "demo/nanochat/evidence/"]
|
||||
struct EvidenceAssets;
|
||||
|
||||
const BOTTLENECK_REPORT: &str =
|
||||
include_str!("../../demo/nanochat/reports/nanochat-bottleneck-diagnosis.md");
|
||||
|
||||
@@ -152,6 +188,10 @@ pub(crate) fn installed_origin(owner: &str, repo: &str) -> Option<PathBuf> {
|
||||
origin.exists().then_some(origin)
|
||||
}
|
||||
|
||||
pub(crate) fn turn_context(project_id: &str) -> Option<&'static str> {
|
||||
(project_id == PROJECT_ID).then_some(TURN_CONTEXT)
|
||||
}
|
||||
|
||||
pub(crate) fn session_start_ref(owner: &str, repo: &str, session_id: &str) -> Option<&'static str> {
|
||||
(owner == OWNER
|
||||
&& repo == REPO
|
||||
@@ -208,8 +248,10 @@ fn seed_at(
|
||||
let bare = data_root.join("demo-repos").join("nanochat.git");
|
||||
let commit_sha = install_repository(repo, &bare)?;
|
||||
|
||||
let files = data_root.join("files").join("nanochat");
|
||||
let project_slug = demo_project_slug(store)?;
|
||||
let files = data_root.join("files").join(&project_slug);
|
||||
std::fs::create_dir_all(&files)?;
|
||||
super::files::ensure_project_brief_contents_at(&files, PROJECT_BRIEF)?;
|
||||
std::fs::write(files.join("cpu-apple-silicon-pipeline-results.md"), REPORT)?;
|
||||
std::fs::write(
|
||||
files.join("nanochat-bottleneck-diagnosis.md"),
|
||||
@@ -220,6 +262,7 @@ fn seed_at(
|
||||
.ok_or_else(|| anyhow!("embedded demo figure is missing: {name}"))?;
|
||||
std::fs::write(files.join(name.as_ref()), asset.data.as_ref())?;
|
||||
}
|
||||
write_assets::<EvidenceAssets>(&files.join("evidence"))?;
|
||||
let logs = data_root.join("run-logs");
|
||||
std::fs::create_dir_all(&logs)?;
|
||||
std::fs::write(logs.join(format!("{RUN_ID}.log")), RUN_LOG)?;
|
||||
@@ -227,7 +270,7 @@ fn seed_at(
|
||||
let project = LocalProject {
|
||||
id: PROJECT_ID.into(),
|
||||
name: "nanochat (demo)".into(),
|
||||
slug: "nanochat".into(),
|
||||
slug: project_slug,
|
||||
github_owner: OWNER.into(),
|
||||
github_repo: REPO.into(),
|
||||
github_sync_enabled: false,
|
||||
@@ -410,6 +453,22 @@ fn seed_at(
|
||||
validate_snapshot(store, repo, newly_created)
|
||||
}
|
||||
|
||||
fn demo_project_slug(store: &Store) -> Result<String> {
|
||||
if let Some(project) = store.get_local_project(PROJECT_ID)? {
|
||||
return Ok(project.slug);
|
||||
}
|
||||
if store.get_local_project_by_slug(PROJECT_SLUG)?.is_none() {
|
||||
return Ok(PROJECT_SLUG.into());
|
||||
}
|
||||
let mut candidate = FALLBACK_PROJECT_SLUG.to_string();
|
||||
let mut suffix = 2;
|
||||
while store.get_local_project_by_slug(&candidate)?.is_some() {
|
||||
candidate = format!("{FALLBACK_PROJECT_SLUG}_{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
fn validate_snapshot(store: &Store, repo: &Path, newly_created: bool) -> Result<DemoCompletion> {
|
||||
let project = store
|
||||
.get_local_project(PROJECT_ID)?
|
||||
@@ -1456,6 +1515,12 @@ mod tests {
|
||||
reasoning_level: None,
|
||||
};
|
||||
let first = seed_at(&store, &data, &repo, selection.clone()).unwrap();
|
||||
let brief_path = data.join("files/nanochat/PROJECT.md");
|
||||
let seeded_brief = std::fs::read_to_string(&brief_path).unwrap();
|
||||
assert!(seeded_brief.contains("# Future Experiments"));
|
||||
assert!(seeded_brief.contains("intentionally omitted to keep the demo bundle small"));
|
||||
let customized_brief = format!("{seeded_brief}\nUser note.\n");
|
||||
std::fs::write(&brief_path, &customized_brief).unwrap();
|
||||
let second = seed_at(
|
||||
&store,
|
||||
&data,
|
||||
@@ -1562,11 +1627,27 @@ mod tests {
|
||||
std::fs::read_dir(data.join("files/nanochat"))
|
||||
.unwrap()
|
||||
.count(),
|
||||
6
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&brief_path).unwrap(),
|
||||
customized_brief
|
||||
);
|
||||
for name in FigureAssets::iter() {
|
||||
assert!(data.join("files/nanochat").join(name.as_ref()).is_file());
|
||||
}
|
||||
for name in EvidenceAssets::iter() {
|
||||
assert!(data
|
||||
.join("files/nanochat/evidence")
|
||||
.join(name.as_ref())
|
||||
.is_file());
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::metadata(data.join("files/nanochat/evidence/tokenizer/tokenizer.pkl"))
|
||||
.unwrap()
|
||||
.len(),
|
||||
412_105
|
||||
);
|
||||
assert!(data
|
||||
.join("files/nanochat/nanochat-bottleneck-diagnosis.md")
|
||||
.is_file());
|
||||
@@ -1582,6 +1663,60 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_seed_preserves_legacy_user_artifacts_with_the_nanochat_slug() {
|
||||
let root = std::env::temp_dir().join(format!("orx-demo-test-{}", uuid::Uuid::new_v4()));
|
||||
let data = root.join("data");
|
||||
let repo = root.join("cache/repos").join(OWNER).join(REPO);
|
||||
let store = Store::open_at(data.clone()).unwrap();
|
||||
store
|
||||
.create_local_project(&LocalProject {
|
||||
id: "user-nanochat".into(),
|
||||
name: "nanochat".into(),
|
||||
slug: PROJECT_SLUG.into(),
|
||||
github_owner: String::new(),
|
||||
github_repo: String::new(),
|
||||
github_sync_enabled: false,
|
||||
baseline_branch: "main".into(),
|
||||
repo_path: root.join("user-nanochat").to_string_lossy().into_owned(),
|
||||
run_command: None,
|
||||
paper_id: None,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
})
|
||||
.unwrap();
|
||||
let user_files = data.join("files").join(PROJECT_SLUG);
|
||||
std::fs::create_dir_all(&user_files).unwrap();
|
||||
let user_report = user_files.join("cpu-apple-silicon-pipeline-results.md");
|
||||
std::fs::write(&user_report, "user-owned\n").unwrap();
|
||||
|
||||
let completion = seed_at(
|
||||
&store,
|
||||
&data,
|
||||
&repo,
|
||||
DemoSelection {
|
||||
harness: "codex".into(),
|
||||
model: None,
|
||||
permission_mode: None,
|
||||
reasoning_level: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(completion.project.slug, FALLBACK_PROJECT_SLUG);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(user_report).unwrap(),
|
||||
"user-owned\n"
|
||||
);
|
||||
assert!(data
|
||||
.join("files")
|
||||
.join(FALLBACK_PROJECT_SLUG)
|
||||
.join("cpu-apple-silicon-pipeline-results.md")
|
||||
.is_file());
|
||||
drop(store);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_commit_ids_are_deterministic() {
|
||||
let root = std::env::temp_dir().join(format!("orx-demo-test-{}", uuid::Uuid::new_v4()));
|
||||
|
||||
+233
-3
@@ -2,7 +2,8 @@
|
||||
//! (`<data dir>/files/<project slug>/`). The filesystem is the source of
|
||||
//! truth: no registry, no upload step. The dashboard's Artifacts tab is an
|
||||
//! explorer over this folder. Files may live directly at the root or in any
|
||||
//! user-chosen nested layout; no filename or directory name is reserved.
|
||||
//! user-chosen nested layout. Root-level `PROJECT.md` is reserved for the
|
||||
//! lightweight project brief that OpenResearch keeps visible in Artifacts.
|
||||
//!
|
||||
//! Serving is contained to the artifacts dir: requested paths are relative
|
||||
//! (`is_safe_rel_path`) and must still resolve inside it once symlinks are
|
||||
@@ -11,6 +12,7 @@
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
@@ -23,6 +25,15 @@ use super::model::LocalProject;
|
||||
/// Files surfaced by the OS that aren't the user's or the agent's.
|
||||
const IGNORED: &[&str] = &[".DS_Store", "Thumbs.db"];
|
||||
|
||||
pub const PROJECT_BRIEF_NAME: &str = "PROJECT.md";
|
||||
pub const MAX_PROJECT_BRIEF_BYTES: usize = 256 * 1024;
|
||||
|
||||
pub fn is_project_brief_path(path: &str) -> bool {
|
||||
path.eq_ignore_ascii_case(PROJECT_BRIEF_NAME)
|
||||
}
|
||||
|
||||
const PROJECT_BRIEF_TEMPLATE: &str = "# Objective\n\nNot defined yet.\n\n# Current Project Summary\n\nNo project summary yet.\n\n# Important Highlights\n\n- None yet.\n\n# Future Experiments\n\n- None proposed yet.\n";
|
||||
|
||||
/// Listing cap — a runaway directory shouldn't stall the 2Hz event loop.
|
||||
const MAX_ENTRIES: usize = 2000;
|
||||
|
||||
@@ -65,6 +76,74 @@ pub fn ensure_dir(project: &LocalProject) -> Result<PathBuf> {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_project_brief_contents_at(dir: &Path, contents: &str) -> Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| anyhow!("Could not create {}: {}", dir.display(), e))?;
|
||||
let path = dir.join(PROJECT_BRIEF_NAME);
|
||||
match std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
if let Err(error) = file.write_all(contents.as_bytes()) {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(anyhow!("Could not write {}: {}", path.display(), error));
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => {
|
||||
return Err(anyhow!("Could not create {}: {}", path.display(), error));
|
||||
}
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn ensure_project_brief_at(dir: &Path) -> Result<PathBuf> {
|
||||
ensure_project_brief_contents_at(dir, PROJECT_BRIEF_TEMPLATE)
|
||||
}
|
||||
|
||||
pub fn ensure_project_brief(project: &LocalProject) -> Result<PathBuf> {
|
||||
ensure_project_brief_at(&files_dir(project))
|
||||
}
|
||||
|
||||
fn read_project_brief_at(dir: &Path) -> Result<String> {
|
||||
let path = dir.join(PROJECT_BRIEF_NAME);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => Ok(content),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
Ok(PROJECT_BRIEF_TEMPLATE.to_string())
|
||||
}
|
||||
Err(error) => Err(anyhow!("Could not read {}: {}", path.display(), error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_project_brief(project: &LocalProject) -> Result<String> {
|
||||
read_project_brief_at(&files_dir(project))
|
||||
}
|
||||
|
||||
fn write_project_brief_at(dir: &Path, content: &str) -> Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| anyhow!("Could not create {}: {}", dir.display(), e))?;
|
||||
let path = dir.join(PROJECT_BRIEF_NAME);
|
||||
let temporary = dir.join(format!(
|
||||
".{PROJECT_BRIEF_NAME}.{}.tmp",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
if let Err(error) =
|
||||
std::fs::write(&temporary, content).and_then(|()| std::fs::rename(&temporary, &path))
|
||||
{
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
return Err(anyhow!("Could not write {}: {}", path.display(), error));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn write_project_brief(project: &LocalProject, content: &str) -> Result<PathBuf> {
|
||||
write_project_brief_at(&files_dir(project), content)
|
||||
}
|
||||
|
||||
/// Relative, no `..`/`.` segments, no backslashes — a requested path can't
|
||||
/// escape the artifacts dir. Lexical only; symlink containment is enforced by
|
||||
/// `resolve_contained`.
|
||||
@@ -329,6 +408,12 @@ fn is_ignored(name: &str) -> bool {
|
||||
name.starts_with('.') || IGNORED.contains(&name)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
left.dev() == right.dev() && left.ino() == right.ino()
|
||||
}
|
||||
|
||||
/// Recursively build the tree under `dir`, counting nodes against
|
||||
/// `MAX_ENTRIES`. Returns (children, hit_cap). Symlinks resolving outside
|
||||
/// `canonical_base` are skipped — the serve endpoints would refuse them.
|
||||
@@ -340,6 +425,27 @@ fn collect_tree(
|
||||
) -> (Vec<ArtifactEntry>, bool) {
|
||||
let mut out = Vec::new();
|
||||
let mut truncated = false;
|
||||
let mut root_brief_metadata = None;
|
||||
if rel_prefix.is_empty() {
|
||||
let brief = dir.join(PROJECT_BRIEF_NAME);
|
||||
if resolves_inside(canonical_base, &brief) {
|
||||
if let Ok(md) = std::fs::metadata(&brief) {
|
||||
if md.is_file() {
|
||||
*seen += 1;
|
||||
out.push(ArtifactEntry {
|
||||
name: PROJECT_BRIEF_NAME.to_string(),
|
||||
path: PROJECT_BRIEF_NAME.to_string(),
|
||||
is_dir: false,
|
||||
size: md.len(),
|
||||
modified_at: mtime_ms(&md),
|
||||
presentation: Some(presentation_for_path(PROJECT_BRIEF_NAME)),
|
||||
children: Vec::new(),
|
||||
});
|
||||
root_brief_metadata = Some(md);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return (out, false);
|
||||
};
|
||||
@@ -348,6 +454,16 @@ fn collect_tree(
|
||||
return (out, true);
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if rel_prefix.is_empty() && is_project_brief_path(&name) {
|
||||
if let (Some(brief), Ok(candidate)) = (
|
||||
root_brief_metadata.as_ref(),
|
||||
std::fs::metadata(entry.path()),
|
||||
) {
|
||||
if same_file(brief, &candidate) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_ignored(&name) {
|
||||
continue;
|
||||
}
|
||||
@@ -385,14 +501,25 @@ fn collect_tree(
|
||||
});
|
||||
}
|
||||
}
|
||||
// Dirs first, then files, each alphabetical — stable explorer order.
|
||||
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)));
|
||||
// PROJECT.md is the durable project overview, so pin it above the ordinary
|
||||
// directory-first alphabetical tree at the artifacts root.
|
||||
out.sort_by(|a, b| {
|
||||
if rel_prefix.is_empty() {
|
||||
let a_brief = is_project_brief_path(&a.path);
|
||||
let b_brief = is_project_brief_path(&b.path);
|
||||
if a_brief != b_brief {
|
||||
return b_brief.cmp(&a_brief);
|
||||
}
|
||||
}
|
||||
b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name))
|
||||
});
|
||||
(out, truncated)
|
||||
}
|
||||
|
||||
/// Scan the artifacts dir (creating it if missing) into a plain file tree.
|
||||
pub fn list(project: &LocalProject) -> Result<ArtifactsListing> {
|
||||
let dir = ensure_dir(project)?;
|
||||
ensure_project_brief_at(&dir)?;
|
||||
let canonical = dir
|
||||
.canonicalize()
|
||||
.map_err(|e| anyhow!("Could not resolve {}: {}", dir.display(), e))?;
|
||||
@@ -416,6 +543,11 @@ pub fn file_path(project: &LocalProject, rel_path: &str) -> Result<PathBuf> {
|
||||
/// followed — but every parent segment must resolve inside the artifacts dir, or
|
||||
/// `a/b` with `a -> /elsewhere` would delete outside it.
|
||||
pub fn delete_entry(project: &LocalProject, rel_path: &str) -> Result<()> {
|
||||
if is_project_brief_path(rel_path) {
|
||||
return Err(anyhow!(
|
||||
"{PROJECT_BRIEF_NAME} is part of the project and cannot be deleted"
|
||||
));
|
||||
}
|
||||
if !is_safe_rel_path(rel_path) {
|
||||
return Err(anyhow!("invalid file path: {rel_path}"));
|
||||
}
|
||||
@@ -569,6 +701,104 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_brief_is_pinned_above_directories() {
|
||||
let (root, base, _) = scratch();
|
||||
std::fs::create_dir(base.join("experiment")).unwrap();
|
||||
std::fs::write(base.join(PROJECT_BRIEF_NAME), "# Objective\n").unwrap();
|
||||
std::fs::write(base.join("notes.md"), "# Notes\n").unwrap();
|
||||
let canonical = base.canonicalize().unwrap();
|
||||
let (entries, truncated) = collect_tree(&canonical, &canonical, "", &mut 0);
|
||||
let names: Vec<&str> = entries.iter().map(|entry| entry.name.as_str()).collect();
|
||||
assert_eq!(names, [PROJECT_BRIEF_NAME, "experiment", "notes.md"]);
|
||||
assert!(!truncated);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_brief_is_listed_even_when_the_entry_cap_is_already_reached() {
|
||||
let (root, base, _) = scratch();
|
||||
std::fs::write(base.join(PROJECT_BRIEF_NAME), "# Objective\n").unwrap();
|
||||
std::fs::write(base.join("notes.md"), "# Notes\n").unwrap();
|
||||
let canonical = base.canonicalize().unwrap();
|
||||
let mut seen = MAX_ENTRIES;
|
||||
let (entries, truncated) = collect_tree(&canonical, &canonical, "", &mut seen);
|
||||
let names: Vec<&str> = entries.iter().map(|entry| entry.name.as_str()).collect();
|
||||
assert_eq!(names, [PROJECT_BRIEF_NAME]);
|
||||
assert!(truncated);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_variant_is_not_hidden_or_duplicated() {
|
||||
let (root, base, _) = scratch();
|
||||
std::fs::write(base.join("project.md"), "# Lowercase\n").unwrap();
|
||||
let expected_name = if base.join(PROJECT_BRIEF_NAME).exists() {
|
||||
PROJECT_BRIEF_NAME
|
||||
} else {
|
||||
"project.md"
|
||||
};
|
||||
let canonical = base.canonicalize().unwrap();
|
||||
let (entries, truncated) = collect_tree(&canonical, &canonical, "", &mut 0);
|
||||
let names: Vec<&str> = entries.iter().map(|entry| entry.name.as_str()).collect();
|
||||
assert_eq!(names, [expected_name]);
|
||||
assert!(!truncated);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_brief_creation_preserves_existing_content() {
|
||||
let (root, base, _) = scratch();
|
||||
let path = ensure_project_brief_at(&base).unwrap();
|
||||
let initial = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(initial, PROJECT_BRIEF_TEMPLATE);
|
||||
|
||||
std::fs::write(&path, "# My custom brief\n").unwrap();
|
||||
ensure_project_brief_at(&base).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(path).unwrap(),
|
||||
"# My custom brief\n"
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_a_missing_brief_is_pure() {
|
||||
let (root, base, _) = scratch();
|
||||
assert_eq!(
|
||||
read_project_brief_at(&base).unwrap(),
|
||||
PROJECT_BRIEF_TEMPLATE
|
||||
);
|
||||
assert!(!base.join(PROJECT_BRIEF_NAME).exists());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_brief_deletion_guard_is_case_insensitive() {
|
||||
assert!(is_project_brief_path(PROJECT_BRIEF_NAME));
|
||||
assert!(is_project_brief_path("project.md"));
|
||||
assert!(!is_project_brief_path("notes/PROJECT.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_brief_writes_replace_the_complete_file() {
|
||||
let (root, base, _) = scratch();
|
||||
ensure_project_brief_at(&base).unwrap();
|
||||
let path = write_project_brief_at(&base, "# Updated\n\nCurrent state.\n").unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(path).unwrap(),
|
||||
"# Updated\n\nCurrent state.\n"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_dir(&base)
|
||||
.unwrap()
|
||||
.filter_map(std::result::Result::ok)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_md_is_an_ordinary_nested_file() {
|
||||
let (root, base, _) = scratch();
|
||||
|
||||
@@ -289,7 +289,11 @@ fn is_readonly_orx(tokens: &[&str], stage: &str) -> bool {
|
||||
|
||||
match verb {
|
||||
// Verbs with a write subcommand: allow only the read-only subcommand(s).
|
||||
"project" => matches!(subcommand(&mut rest), Some("view")),
|
||||
"project" => match subcommand(&mut rest) {
|
||||
Some("view") => true,
|
||||
Some("brief") => matches!(subcommand(&mut rest), Some("show")),
|
||||
_ => false,
|
||||
},
|
||||
"exp" => match subcommand(&mut rest) {
|
||||
// Pure reads.
|
||||
Some("status" | "wait") => true,
|
||||
@@ -466,6 +470,7 @@ mod tests {
|
||||
#[test]
|
||||
fn read_only_subcommands_are_allowed() {
|
||||
assert!(allowed("orx project view p-1"));
|
||||
assert!(allowed("orx project brief show p-1"));
|
||||
assert!(allowed("orx exp status e-1"));
|
||||
// The playbook's core orientation reads (view form).
|
||||
assert!(allowed("orx exp desc e-1"));
|
||||
@@ -480,6 +485,9 @@ mod tests {
|
||||
fn write_subcommands_are_gated() {
|
||||
// Same verb, write subcommand → not allowed (plan mode gates it).
|
||||
assert!(!allowed("orx project edit p-1 --name x"));
|
||||
assert!(!allowed("orx project brief update p-1 --stdin"));
|
||||
assert!(!allowed("orx project brief --stdin update p-1"));
|
||||
assert!(!allowed("orx project --stdin brief update p-1"));
|
||||
assert!(!allowed("orx exp run e-1"));
|
||||
assert!(!allowed("orx exp cancel e-1"));
|
||||
assert!(!allowed("orx report upload r-1 --file x.md"));
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ pub fn unsupported(cmd: &str) -> Error {
|
||||
};
|
||||
anyhow!(
|
||||
"`orx {cmd}` is not supported in local mode yet.\n\
|
||||
Local mode supports: projects, project view/edit, create-experiment, \
|
||||
Local mode supports: projects, project view/edit/brief, create-experiment, \
|
||||
exp run/status/cancel/wait/wake/desc, runs, logs.{guidance}"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -751,6 +751,21 @@ mod tests {
|
||||
assert!(!md.contains("OpenResearch injects an `[orx]` message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playbook_makes_project_brief_descriptive_not_authoritative() {
|
||||
let md = sample_playbook();
|
||||
assert!(md.contains("PROJECT.md describes the project; the user directs it"));
|
||||
assert!(md.contains("the user's latest request"));
|
||||
assert!(md.contains("descriptive, never prescriptive"));
|
||||
assert!(
|
||||
md.contains("Never refuse, delay, redirect, or ask for confirmation solely because")
|
||||
);
|
||||
assert!(md.contains("Do not read the brief at session start"));
|
||||
assert!(md.contains("orx project brief update"));
|
||||
assert!(!md.contains("No project summary yet."));
|
||||
assert!(!md.contains("None proposed yet."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playbook_requires_clickable_file_references() {
|
||||
let md = sample_playbook();
|
||||
|
||||
+12
-1
@@ -20,7 +20,7 @@ fn unique_project_slug(store: &Store, base: &str) -> Result<String> {
|
||||
.into_iter()
|
||||
.map(|p| p.slug)
|
||||
.collect();
|
||||
if !taken.contains(base) {
|
||||
if base != super::demo::PROJECT_SLUG && !taken.contains(base) {
|
||||
return Ok(base.to_string());
|
||||
}
|
||||
let mut n = 2;
|
||||
@@ -237,6 +237,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserves_the_demo_slug_for_onboarding() {
|
||||
let root = root();
|
||||
let store = Store::open_at(root.join("data")).unwrap();
|
||||
assert_eq!(
|
||||
project_slug_preview(&store, "nanochat").unwrap(),
|
||||
"nanochat-2"
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_local_only_project_without_a_remote() {
|
||||
let root = root();
|
||||
|
||||
+19
@@ -250,6 +250,25 @@ pub enum ProjectCommand {
|
||||
#[arg(long, conflicts_with = "public")]
|
||||
private: bool,
|
||||
},
|
||||
|
||||
/// Read or update the local project's user-facing PROJECT.md.
|
||||
Brief {
|
||||
#[command(subcommand)]
|
||||
command: ProjectBriefCommand,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ProjectBriefCommand {
|
||||
/// Print the current project brief, or the default template if missing.
|
||||
Show { project_id: String },
|
||||
|
||||
/// Replace the project brief with Markdown read from stdin.
|
||||
Update {
|
||||
project_id: String,
|
||||
#[arg(long, required = true)]
|
||||
stdin: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+191
-191
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -26,8 +26,8 @@
|
||||
html { background: #ffffff; }
|
||||
html[data-theme="dark"] { background: #0e0c0c; }
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-CwiOikQ2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DnG1jz59.css">
|
||||
<script type="module" crossorigin src="/assets/index-CoJe5szK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-4dpWgB72.css">
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
|
||||
+155
-60
@@ -19,12 +19,15 @@ import {
|
||||
cancelRun,
|
||||
DEMO_FIGURE_SESSION_ID,
|
||||
DEMO_LITERATURE_SESSION_ID,
|
||||
DEMO_MAIN_SESSION_ID,
|
||||
getArtifacts,
|
||||
getUiState,
|
||||
isDemoProjectId,
|
||||
listExperiments,
|
||||
listProjects,
|
||||
listRuns,
|
||||
openProject,
|
||||
PROJECT_BRIEF_NAME,
|
||||
updateUiState,
|
||||
type AgentSelection,
|
||||
type Experiment,
|
||||
@@ -49,7 +52,7 @@ import { ExperimentsTable } from "./components/ExperimentsTable";
|
||||
import { Md } from "./components/Md";
|
||||
import { usePopover } from "./components/ModelPicker";
|
||||
import { SettingsView, type SettingsTab } from "./components/SettingsPage";
|
||||
import { Tour } from "./components/Tour";
|
||||
import { DemoWelcomeModal } from "./components/Tour";
|
||||
import { clearReadDemoSessions } from "./demoSessionState";
|
||||
import { TreeView } from "./components/TreeView";
|
||||
import { useOrxEvents } from "./events";
|
||||
@@ -195,7 +198,10 @@ interface RightPaneSessionState {
|
||||
panelMax: boolean;
|
||||
}
|
||||
|
||||
function initialRightPaneSessionState(sessionId?: string): RightPaneSessionState {
|
||||
function initialRightPaneSessionState(
|
||||
sessionId?: string,
|
||||
openDemoBrief = false,
|
||||
): RightPaneSessionState {
|
||||
const initial: RightPaneSessionState = {
|
||||
rightTab: "experiments",
|
||||
tabHistory: ["experiments"],
|
||||
@@ -214,6 +220,18 @@ function initialRightPaneSessionState(sessionId?: string): RightPaneSessionState
|
||||
panelOpen: true,
|
||||
panelMax: false,
|
||||
};
|
||||
if (sessionId === DEMO_MAIN_SESSION_ID && openDemoBrief) {
|
||||
const projectBriefTab: FileViewDef = {
|
||||
path: PROJECT_BRIEF_NAME,
|
||||
source: "artifacts",
|
||||
};
|
||||
return {
|
||||
...initial,
|
||||
rightTab: projectBriefTab,
|
||||
tabHistory: ["experiments", projectBriefTab],
|
||||
fileTabs: [projectBriefTab],
|
||||
};
|
||||
}
|
||||
if (sessionId === DEMO_FIGURE_SESSION_ID) {
|
||||
const fileTabs: FileViewDef[] = [
|
||||
{ path: "nanochat-base-training-curves.svg", source: "artifacts" },
|
||||
@@ -336,6 +354,8 @@ const LAYOUT_CHROME = RAIL_WIDTH + 14 * 4;
|
||||
// Once a drag pushes the panel past its usable max by this much, it snaps to
|
||||
// fullscreen — a bit of resistance you have to overcome deliberately.
|
||||
const FULLSCREEN_SNAP_SLOP = 80;
|
||||
// Inward drag needed before snapping back to the last non-fullscreen width.
|
||||
const FULLSCREEN_RESTORE_DRAG = 48;
|
||||
|
||||
/** The widest the floating panel can be while leaving the rail + chat usable. */
|
||||
function panelMaxWidth(): number {
|
||||
@@ -372,6 +392,9 @@ function useStableStringMap(next: Map<string, string>): Map<string, string> {
|
||||
export default function App() {
|
||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||
const [uiState, setUiState] = useState<UiState | null>(null);
|
||||
const tourCompletedRef = useRef<boolean | undefined>(undefined);
|
||||
tourCompletedRef.current = uiState?.tourCompleted;
|
||||
const demoBriefSeededRef = useRef(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
const persistedPreferredAgent = useRef<AgentSelection | null>(null);
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
@@ -393,6 +416,7 @@ export default function App() {
|
||||
const { open: scopeMenuOpen, setOpen: setScopeMenuOpen, ref: scopeMenuRef } =
|
||||
usePopover(scopeTriggerRef);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [demoBriefLeading, setDemoBriefLeading] = useState(false);
|
||||
const allExperimentsAttributed = experiments.every((experiment) => experiment.chatSessionId);
|
||||
const effectiveScope = activeSessionId && allExperimentsAttributed ? scope : "project";
|
||||
const scopedExperiments = useMemo(() => {
|
||||
@@ -498,10 +522,18 @@ export default function App() {
|
||||
if (previousSessionId) {
|
||||
rightPaneStatesRef.current.set(previousSessionId, currentRightPaneStateRef.current);
|
||||
}
|
||||
const nextState = nextSessionId
|
||||
? (rightPaneStatesRef.current.get(nextSessionId) ??
|
||||
initialRightPaneSessionState(nextSessionId))
|
||||
: initialRightPaneSessionState();
|
||||
let nextState = nextSessionId ? rightPaneStatesRef.current.get(nextSessionId) : undefined;
|
||||
if (!nextState) {
|
||||
const openDemoBrief =
|
||||
nextSessionId === DEMO_MAIN_SESSION_ID &&
|
||||
tourCompletedRef.current === false &&
|
||||
!demoBriefSeededRef.current;
|
||||
if (openDemoBrief) {
|
||||
demoBriefSeededRef.current = true;
|
||||
setDemoBriefLeading(true);
|
||||
}
|
||||
nextState = initialRightPaneSessionState(nextSessionId ?? undefined, openDemoBrief);
|
||||
}
|
||||
setRightTab(nextState.rightTab);
|
||||
tabHistoryRef.current = nextState.tabHistory;
|
||||
setTabHistory(nextState.tabHistory);
|
||||
@@ -523,33 +555,25 @@ export default function App() {
|
||||
setActiveSessionId(nextSessionId);
|
||||
}, []);
|
||||
const onboarded = uiState?.onboardingCompleted ?? false;
|
||||
// The spotlight tour of the workspace (Tour.tsx). Starting it normalizes
|
||||
// the layout so every tour target exists; those are the defaults, so
|
||||
// nothing needs restoring on finish/skip.
|
||||
const [tourOpen, setTourOpen] = useState(false);
|
||||
const startTour = useCallback(() => {
|
||||
setMainView("chat");
|
||||
setRailOpen(true);
|
||||
setExperimentsTabOpen(true);
|
||||
selectRightTab("experiments");
|
||||
setPanelOpen(true);
|
||||
setPanelMax(false);
|
||||
setTourOpen(true);
|
||||
}, [selectRightTab]);
|
||||
const closeTour = useCallback(async () => {
|
||||
const [demoWelcomeOpen, setDemoWelcomeOpen] = useState(false);
|
||||
const openDemoWelcome = useCallback(() => setDemoWelcomeOpen(true), []);
|
||||
const closeDemoWelcome = useCallback(async () => {
|
||||
const saved = await updateUiState({ tourCompleted: true });
|
||||
setUiState((current) => current && { ...current, tourCompleted: saved.tourCompleted });
|
||||
setTourOpen(false);
|
||||
setDemoWelcomeOpen(false);
|
||||
}, []);
|
||||
const createProjectFromDemoWelcome = useCallback(async () => {
|
||||
await closeDemoWelcome();
|
||||
setNewProjectOpen(true);
|
||||
}, [closeDemoWelcome]);
|
||||
|
||||
// Auto-start the tour the first time the workspace is actually on screen:
|
||||
// first-run walkthrough done, a project open, projects home closed. With
|
||||
// zero projects this waits until the first one is created and opened.
|
||||
// Show the welcome once the bundled demo is first visible. User projects
|
||||
// never open it automatically.
|
||||
useEffect(() => {
|
||||
if (!projectId || homeOpen || !onboarded) return;
|
||||
if (!projectId || !isDemoProjectId(projectId) || homeOpen || !onboarded) return;
|
||||
if (uiState?.tourCompleted) return;
|
||||
startTour();
|
||||
}, [projectId, homeOpen, onboarded, startTour, uiState?.tourCompleted]);
|
||||
openDemoWelcome();
|
||||
}, [projectId, homeOpen, onboarded, openDemoWelcome, uiState?.tourCompleted]);
|
||||
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
@@ -636,6 +660,7 @@ export default function App() {
|
||||
setSelectedRunId(null);
|
||||
setExpTabs([]);
|
||||
setFileTabs([]);
|
||||
setDemoBriefLeading(false);
|
||||
setPlanTabs([]);
|
||||
setSubagentTabs([]);
|
||||
setCodeTabs([]);
|
||||
@@ -765,6 +790,22 @@ export default function App() {
|
||||
[expTabs, forgetRightTab, rightTab],
|
||||
);
|
||||
|
||||
const openResolvedFileTab = useCallback(
|
||||
(tab: FileViewDef) => {
|
||||
const persistentTab = persistentFileTab(tab);
|
||||
setFileTabs((prev) => {
|
||||
const idx = prev.findIndex((item) => sameFileTab(item, tab));
|
||||
if (idx === -1) return [...prev, persistentTab];
|
||||
const next = prev.slice();
|
||||
next[idx] = persistentTab;
|
||||
return next;
|
||||
});
|
||||
selectRightTab(tab);
|
||||
setPanelOpen(true);
|
||||
},
|
||||
[selectRightTab],
|
||||
);
|
||||
|
||||
// Open a project file as a right-panel tab. `contextSessionId` is the chat
|
||||
// session (or viewed file's session) the click came from — see
|
||||
// parseFilePath for how it resolves against the reported path.
|
||||
@@ -807,18 +848,14 @@ export default function App() {
|
||||
// Line is not part of tab identity: reopening a file at a new line reuses
|
||||
// the tab but makes the new (line-carrying) def the active one so the
|
||||
// viewer re-scrolls.
|
||||
const persistentTab = persistentFileTab(tab);
|
||||
setFileTabs((prev) => {
|
||||
const idx = prev.findIndex((item) => sameFileTab(item, tab));
|
||||
if (idx === -1) return [...prev, persistentTab];
|
||||
const next = prev.slice();
|
||||
next[idx] = persistentTab;
|
||||
return next;
|
||||
});
|
||||
selectRightTab(tab);
|
||||
setPanelOpen(true);
|
||||
openResolvedFileTab(tab);
|
||||
},
|
||||
[projects, projectId, selectRightTab],
|
||||
[projects, projectId, openResolvedFileTab],
|
||||
);
|
||||
|
||||
const openArtifactFileTab = useCallback(
|
||||
(path: string) => openResolvedFileTab({ path, source: "artifacts" }),
|
||||
[openResolvedFileTab],
|
||||
);
|
||||
|
||||
// Chat file chips may carry a target line, cited experiment, or an exact Git
|
||||
@@ -837,6 +874,12 @@ export default function App() {
|
||||
if (projectId) {
|
||||
fileScrollPositionsRef.current.delete(fileScrollKey(projectId, activeSessionId, tab));
|
||||
}
|
||||
if (
|
||||
activeSessionId === DEMO_MAIN_SESSION_ID &&
|
||||
sameFileTab(tab, { path: PROJECT_BRIEF_NAME, source: "artifacts" })
|
||||
) {
|
||||
setDemoBriefLeading(false);
|
||||
}
|
||||
forgetRightTab(tab, rightTabKey(rightTab) === rightTabKey(tab));
|
||||
},
|
||||
[activeSessionId, fileTabs, forgetRightTab, projectId, rightTab],
|
||||
@@ -968,10 +1011,39 @@ export default function App() {
|
||||
e.preventDefault();
|
||||
// Capture the pointer so the terminal/diff views under the cursor don't
|
||||
// steal the drag, and suppress text selection for its duration.
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
const handle = e.currentTarget;
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
const prevUserSelect = document.body.style.userSelect;
|
||||
document.body.style.userSelect = "none";
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const startedMaximized = panelMax;
|
||||
const startX = e.clientX;
|
||||
const startWidth = panelWidth;
|
||||
let restoredFromMax = false;
|
||||
function stop() {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", stop);
|
||||
window.removeEventListener("pointercancel", stop);
|
||||
document.body.style.userSelect = prevUserSelect;
|
||||
}
|
||||
function onMove(ev: PointerEvent) {
|
||||
if (startedMaximized) {
|
||||
const distance = ev.clientX - startX;
|
||||
if (restoredFromMax || distance < FULLSCREEN_RESTORE_DRAG) return;
|
||||
restoredFromMax = true;
|
||||
setPanelMax(false);
|
||||
const width = Math.min(
|
||||
Math.max(startWidth, PANEL_MIN_WIDTH),
|
||||
panelMaxWidth(),
|
||||
);
|
||||
setPanelWidth(width);
|
||||
try {
|
||||
localStorage.setItem(PANEL_WIDTH_KEY, String(width));
|
||||
} catch {
|
||||
// best-effort persistence
|
||||
}
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
return;
|
||||
}
|
||||
const w = Math.round(window.innerWidth - ev.clientX - PANEL_MARGIN);
|
||||
const max = panelMaxWidth();
|
||||
// Drag past the usable max by the slop threshold → snap to fullscreen.
|
||||
@@ -988,13 +1060,7 @@ export default function App() {
|
||||
} catch {
|
||||
// best-effort persistence
|
||||
}
|
||||
};
|
||||
const stop = () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", stop);
|
||||
window.removeEventListener("pointercancel", stop);
|
||||
document.body.style.userSelect = prevUserSelect;
|
||||
};
|
||||
}
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", stop);
|
||||
window.addEventListener("pointercancel", stop);
|
||||
@@ -1018,6 +1084,21 @@ export default function App() {
|
||||
const expTab =
|
||||
typeof rightTab === "object" && "id" in rightTab ? rightTab : null;
|
||||
const fileTab = typeof rightTab === "object" && "path" in rightTab ? rightTab : null;
|
||||
const onboardingProjectBriefTab =
|
||||
activeSessionId === DEMO_MAIN_SESSION_ID && demoBriefLeading
|
||||
? fileTabs.find(
|
||||
(tab) =>
|
||||
sameFileTab(tab, {
|
||||
path: PROJECT_BRIEF_NAME,
|
||||
source: "artifacts",
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
// The demo brief leads the home tabs; every other file keeps the normal slot.
|
||||
const leadingFileTabs = onboardingProjectBriefTab ? [onboardingProjectBriefTab] : [];
|
||||
const trailingFileTabs = onboardingProjectBriefTab
|
||||
? fileTabs.filter((tab) => !sameFileTab(tab, onboardingProjectBriefTab))
|
||||
: fileTabs;
|
||||
// PlanViewDef and SubagentViewDef both carry `kind`; discriminate on its value.
|
||||
const planTab =
|
||||
typeof rightTab === "object" && "kind" in rightTab && rightTab.kind === "plan"
|
||||
@@ -1032,6 +1113,16 @@ export default function App() {
|
||||
const codeTab = requestedCodeTab
|
||||
? (codeTabs.find((tab) => sameCodeTab(tab, requestedCodeTab)) ?? null)
|
||||
: null;
|
||||
const renderFileTab = (tab: FileViewDef) => (
|
||||
<ClosableTab
|
||||
key={`file:${fileTabKey(tab)}`}
|
||||
active={fileTab !== null && sameFileTab(fileTab, tab)}
|
||||
label={tab.path.split("/").pop() || tab.path}
|
||||
icon={<FileCode size={12} style={{ flexShrink: 0 }} />}
|
||||
onSelect={() => selectRightTab(tab)}
|
||||
onClose={() => closeFileTab(tab)}
|
||||
/>
|
||||
);
|
||||
const activeProject = projects?.find((p) => p.id === projectId) ?? null;
|
||||
const tabExperiment = expTab ? (experiments.find((e) => e.id === expTab.id) ?? null) : null;
|
||||
const codeExperiment = codeTab
|
||||
@@ -1139,7 +1230,9 @@ export default function App() {
|
||||
onOpenPlan={openPlanTab}
|
||||
onOpenSubagent={openSubagentTab}
|
||||
onOpenWorktree={openWorktreeTab}
|
||||
onStartTour={startTour}
|
||||
onOpenDemoWelcome={
|
||||
activeProject && isDemoProjectId(activeProject.id) ? openDemoWelcome : undefined
|
||||
}
|
||||
onActiveSessionChange={onActiveSessionChange}
|
||||
preferredAgent={uiState.preferredAgent}
|
||||
onPreferredAgentChange={persistPreferredAgent}
|
||||
@@ -1170,9 +1263,14 @@ export default function App() {
|
||||
style={panelMax ? undefined : { width: panelWidth }}
|
||||
data-onboarding="experiments"
|
||||
>
|
||||
{!panelMax && <div className="panel-resizer absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize z-30 [&:hover]:bg-[color-mix(in_oklab,_var(--text)_12%,_transparent)] [&:active]:bg-[color-mix(in_oklab,_var(--text)_12%,_transparent)]" onPointerDown={resizePanel} />}
|
||||
<div
|
||||
className={`panel-resizer absolute left-0 top-0 bottom-0 w-1.5 z-30 [&:hover]:bg-[color-mix(in_oklab,_var(--text)_12%,_transparent)] [&:active]:bg-[color-mix(in_oklab,_var(--text)_12%,_transparent)] ${panelMax ? "cursor-e-resize" : "cursor-col-resize"}`}
|
||||
title={panelMax ? "Drag right to restore panel" : "Drag to resize panel"}
|
||||
onPointerDown={resizePanel}
|
||||
/>
|
||||
<div className="tabs flex items-end gap-0 pt-1 pr-1.5 pb-0 pl-2 h-10 border-b border-b-border bg-background shrink-0">
|
||||
<div className="tab-strip flex items-end gap-0.5 flex-1 min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{leadingFileTabs.map(renderFileTab)}
|
||||
{artifactsTabOpen && (
|
||||
<ClosableTab
|
||||
active={rightTab === "artifacts"}
|
||||
@@ -1219,16 +1317,7 @@ export default function App() {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{fileTabs.map((t) => (
|
||||
<ClosableTab
|
||||
key={`file:${fileTabKey(t)}`}
|
||||
active={fileTab !== null && sameFileTab(fileTab, t)}
|
||||
label={t.path.split("/").pop() || t.path}
|
||||
icon={<FileCode size={12} style={{ flexShrink: 0 }} />}
|
||||
onSelect={() => selectRightTab(t)}
|
||||
onClose={() => closeFileTab(t)}
|
||||
/>
|
||||
))}
|
||||
{trailingFileTabs.map(renderFileTab)}
|
||||
{planTabs.map((t) => (
|
||||
<ClosableTab
|
||||
key={`plan:${t.promptId}`}
|
||||
@@ -1293,6 +1382,7 @@ export default function App() {
|
||||
project={activeProject}
|
||||
artifacts={artifacts}
|
||||
onChanged={refreshArtifacts}
|
||||
onOpenFile={openArtifactFileTab}
|
||||
onOpenStorage={() => setMainView("storage")}
|
||||
/>
|
||||
)}
|
||||
@@ -1546,7 +1636,12 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tourOpen && !homeOpen && projectId && <Tour onClose={closeTour} />}
|
||||
{demoWelcomeOpen && !homeOpen && activeProject && isDemoProjectId(activeProject.id) && (
|
||||
<DemoWelcomeModal
|
||||
onClose={closeDemoWelcome}
|
||||
onCreateProject={createProjectFromDemoWelcome}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Typed client for the orx up local HTTP API (/api/*). All wire JSON is camelCase.
|
||||
|
||||
export const DEMO_PROJECT_ID = "demo_nanochat_v1";
|
||||
export const PROJECT_BRIEF_NAME = "PROJECT.md";
|
||||
// Bundled demo snapshots reserve this prefix so future demos inherit demo-only UI.
|
||||
export const isDemoProjectId = (id: string) => id.startsWith("demo_");
|
||||
export const DEMO_MAIN_SESSION_ID = "chat_demo_nanochat_v1";
|
||||
export const DEMO_FIGURE_SESSION_ID = "chat_demo_nanochat_figures_v1";
|
||||
export const DEMO_LITERATURE_SESSION_ID = "chat_demo_nanochat_literature_v1";
|
||||
@@ -755,6 +758,9 @@ export const getArtifactFileText = (
|
||||
});
|
||||
};
|
||||
|
||||
export const saveProjectBrief = (projectId: string, content: string) =>
|
||||
put<{ ok: boolean; bytesWritten: number }>(`/api/projects/${projectId}/brief`, { content });
|
||||
|
||||
const isFilePresentation = (value: string | null): value is FilePresentation =>
|
||||
value === "image" || value === "audio" || value === "video" || value === "pdf" ||
|
||||
value === "text" || value === "unknown" || value === "download";
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
FileText,
|
||||
MousePointerClick,
|
||||
Package,
|
||||
Pencil,
|
||||
Settings2,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
@@ -21,6 +23,8 @@ import {
|
||||
FILE_PREVIEW_BYTES,
|
||||
fmtBytes,
|
||||
getArtifactFileText,
|
||||
PROJECT_BRIEF_NAME,
|
||||
saveProjectBrief,
|
||||
type ArtifactEntry,
|
||||
type Project,
|
||||
type ProjectArtifacts,
|
||||
@@ -30,7 +34,7 @@ import { FileTypeIcon, isMarkdownFile } from "./FileTypeIcon";
|
||||
import { MediaPreview, mediaPreviewKind, type MediaPreviewKind } from "./MediaPreview";
|
||||
import { normalizeMarkdownForRendering } from "../markdownNormalization";
|
||||
import { mdCodeComponents, remarkMathOptions } from "./Md";
|
||||
import { ICON_BUTTON_BASE_CLASS_NAME, ICON_BUTTON_CLASS_NAME, SETTINGS_LOADING_CLASS_NAME, SPINNER_CLASS_NAME } from "../styleClasses";
|
||||
import { ICON_BUTTON_BASE_CLASS_NAME, ICON_BUTTON_CLASS_NAME, SETTINGS_LOADING_CLASS_NAME, SMALL_BUTTON_CLASS_NAME, SMALL_PRIMARY_BUTTON_CLASS_NAME, SPINNER_CLASS_NAME } from "../styleClasses";
|
||||
|
||||
const TOOLTIP_ICON_BUTTON_CLASS_NAME = `${ICON_BUTTON_CLASS_NAME} tip-up [&[data-tip]::after]:top-auto [&[data-tip]::after]:bottom-[calc(100%_+_6px)]`;
|
||||
|
||||
@@ -83,6 +87,7 @@ const TREE_WIDTH_KEY = "orx:files-tree-width";
|
||||
const COLLAPSED_DIRS_KEY_PREFIX = "orx:artifacts-collapsed:";
|
||||
const TREE_MIN_WIDTH = 180;
|
||||
const TREE_MAX_WIDTH = 560;
|
||||
const TREE_MAX_INDENT_DEPTH = 8;
|
||||
const TREE_DEFAULT_WIDTH = 280;
|
||||
|
||||
function initialTreeWidth(): number {
|
||||
@@ -184,45 +189,61 @@ function previewKind(entry: ArtifactEntry): PreviewKind {
|
||||
(entry.presentation === "text" || entry.presentation === "unknown" ? "text" : "download");
|
||||
}
|
||||
|
||||
function isProjectBriefPath(path: string): boolean {
|
||||
return path.toLowerCase() === PROJECT_BRIEF_NAME.toLowerCase();
|
||||
}
|
||||
|
||||
/** Fetched body for kinds that need text: markdown or raw text. */
|
||||
function useTextBody(projectId: string, entry: ArtifactEntry, kind: PreviewKind) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [binary, setBinary] = useState(false);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const requestSequence = useRef(0);
|
||||
const hasText = useRef(false);
|
||||
|
||||
const wantsText = kind === "markdown" || (kind === "text" && entry.size <= FILE_PREVIEW_BYTES);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset before the wantsText guard: a refire on the same mounted entry
|
||||
// (modifiedAt changed — file rewritten on disk) must not leave the
|
||||
// previous body or binary/error flags behind.
|
||||
setText(null);
|
||||
// Keep the previous body visible while a same-path rewrite is refetched.
|
||||
// The preview component remounts when the selected path changes.
|
||||
setBinary(false);
|
||||
setTruncated(false);
|
||||
setError(null);
|
||||
if (!wantsText) return;
|
||||
let cancelled = false;
|
||||
const request = ++requestSequence.current;
|
||||
const load = getArtifactFileText(projectId, entry.path).then((body) => {
|
||||
if (!body) throw new Error("Artifact not found");
|
||||
return body;
|
||||
});
|
||||
load
|
||||
.then((body) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || request !== requestSequence.current) return;
|
||||
if (body.binary) setBinary(true);
|
||||
else setText(body.content);
|
||||
else {
|
||||
hasText.current = true;
|
||||
setText(body.content);
|
||||
}
|
||||
setTruncated(body.truncated);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
if (!cancelled && request === requestSequence.current && !hasText.current) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, entry.path, entry.modifiedAt, kind, wantsText]);
|
||||
|
||||
return { text, binary, truncated, error, wantsText };
|
||||
const replaceText = (value: string) => {
|
||||
requestSequence.current += 1;
|
||||
hasText.current = true;
|
||||
setText(value);
|
||||
};
|
||||
|
||||
return { text, replaceText, binary, truncated, error, wantsText };
|
||||
}
|
||||
|
||||
/** Right pane: the selected artifact rendered inline — markdown as a document,
|
||||
@@ -231,20 +252,55 @@ function PreviewPane({
|
||||
projectId,
|
||||
entry,
|
||||
onDelete,
|
||||
onChanged,
|
||||
}: {
|
||||
projectId: string;
|
||||
entry: ArtifactEntry;
|
||||
onDelete: (path: string) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const kind = previewKind(entry);
|
||||
const { text, binary, truncated, error, wantsText } = useTextBody(projectId, entry, kind);
|
||||
const { text, replaceText, binary, truncated, error, wantsText } = useTextBody(projectId, entry, kind);
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const isDoc = kind === "markdown";
|
||||
const isProjectBrief = isProjectBriefPath(entry.path);
|
||||
const mdFolder = entry.path.split("/").slice(0, -1).join("/");
|
||||
const rawUrl = `${artifactUrl(projectId, entry.path)}&v=${entry.modifiedAt}`;
|
||||
|
||||
const saveBrief = async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await saveProjectBrief(projectId, draft);
|
||||
replaceText(draft);
|
||||
setEditing(false);
|
||||
setShowSource(false);
|
||||
onChanged();
|
||||
} catch (saveFailure) {
|
||||
setSaveError(saveFailure instanceof Error ? saveFailure.message : String(saveFailure));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
let body: ReactNode;
|
||||
if (kind === "image" || kind === "audio" || kind === "video" || kind === "pdf") {
|
||||
if (editing) {
|
||||
body = (
|
||||
<div className="flex h-full min-h-0 flex-col gap-2 p-4">
|
||||
<textarea
|
||||
className="min-h-0 flex-1 resize-none font-mono text-sm leading-relaxed"
|
||||
aria-label="Edit project brief"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
{saveError && <div className="text-sm text-accent-red">Could not save: {saveError}</div>}
|
||||
</div>
|
||||
);
|
||||
} else if (kind === "image" || kind === "audio" || kind === "video" || kind === "pdf") {
|
||||
body = <MediaPreview kind={kind} url={rawUrl} name={entry.name} />;
|
||||
} else if (kind === "download" || !wantsText || binary) {
|
||||
body = (
|
||||
@@ -294,7 +350,49 @@ function PreviewPane({
|
||||
{(kind === "text" || kind === "download") && (
|
||||
<span className="fpreview-size text-xs text-muted whitespace-nowrap shrink-0">{fmtBytes(entry.size)}</span>
|
||||
)}
|
||||
{isDoc && (
|
||||
{isProjectBrief && !editing && text !== null && !truncated && (
|
||||
<button
|
||||
className={SMALL_BUTTON_CLASS_NAME}
|
||||
data-tip="Edit project brief"
|
||||
data-tip-align="end"
|
||||
aria-label="Edit project brief"
|
||||
onClick={() => {
|
||||
setDraft(text);
|
||||
setSaveError(null);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={13} /> Edit
|
||||
</button>
|
||||
)}
|
||||
{isProjectBrief && editing && (
|
||||
<>
|
||||
<button
|
||||
className={SMALL_BUTTON_CLASS_NAME}
|
||||
data-tip="Cancel editing"
|
||||
data-tip-align="end"
|
||||
aria-label="Cancel editing"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setSaveError(null);
|
||||
}}
|
||||
>
|
||||
<X size={13} /> Cancel
|
||||
</button>
|
||||
<button
|
||||
className={SMALL_PRIMARY_BUTTON_CLASS_NAME}
|
||||
data-tip={saving ? "Saving…" : "Save project brief"}
|
||||
data-tip-align="end"
|
||||
aria-label={saving ? "Saving project brief" : "Save project brief"}
|
||||
disabled={saving}
|
||||
onClick={() => void saveBrief()}
|
||||
>
|
||||
<Check size={13} /> {saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isDoc && !editing && (
|
||||
<button
|
||||
className={`${ICON_BUTTON_CLASS_NAME} ${showSource ? "active" : ""}`}
|
||||
data-tip={showSource ? "Rendered view" : "View source"}
|
||||
@@ -316,20 +414,22 @@ function PreviewPane({
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
</a>
|
||||
<button
|
||||
className={ICON_BUTTON_CLASS_NAME}
|
||||
data-tip="Delete artifact"
|
||||
data-tip-align="end"
|
||||
aria-label="Delete artifact"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete "${entry.path}" from the artifacts directory?`))
|
||||
onDelete(entry.path);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
{!isProjectBrief && (
|
||||
<button
|
||||
className={ICON_BUTTON_CLASS_NAME}
|
||||
data-tip="Delete artifact"
|
||||
data-tip-align="end"
|
||||
aria-label="Delete artifact"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete "${entry.path}" from the artifacts directory?`))
|
||||
onDelete(entry.path);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={`fpreview-body flex-1 min-h-0 overflow-auto [&.doc]:pt-4.5 [&.doc]:px-7 [&.doc]:pb-12 [&.doc_.artifact-md]:max-w-readable [&.doc_.artifact-md]:my-0 [&.doc_.artifact-md]:mx-auto ${isDoc && !showSource ? "doc" : ""}`}>
|
||||
<div className={`fpreview-body flex-1 min-h-0 overflow-auto [&.doc]:pt-4.5 [&.doc]:px-7 [&.doc]:pb-12 [&.doc_.artifact-md]:max-w-readable [&.doc_.artifact-md]:my-0 [&.doc_.artifact-md]:mx-auto ${isDoc && !showSource && !editing ? "doc" : ""}`}>
|
||||
{body}
|
||||
{truncated && (
|
||||
<div className="file-view-note py-2.5 px-4 text-sm text-muted">
|
||||
@@ -348,6 +448,7 @@ function TreeRows({
|
||||
selected,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onOpenFile,
|
||||
onDelete,
|
||||
}: {
|
||||
entries: ArtifactEntry[];
|
||||
@@ -356,17 +457,18 @@ function TreeRows({
|
||||
selected: string | null;
|
||||
onToggle: (path: string) => void;
|
||||
onSelect: (path: string) => void;
|
||||
onOpenFile: (path: string) => void;
|
||||
onDelete: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full max-w-full min-w-0 flex-col items-stretch">
|
||||
{entries.map((e) => {
|
||||
const indent = { paddingLeft: 8 + depth * 14 };
|
||||
const indent = { paddingLeft: 8 + Math.min(depth, TREE_MAX_INDENT_DEPTH) * 14 };
|
||||
if (e.isDir) {
|
||||
const open = !collapsed.has(e.path);
|
||||
return (
|
||||
<div key={e.path}>
|
||||
<div className="file-tree-row flex items-center gap-1.5 w-full py-[3px] px-2.5 border-0 bg-transparent text-text text-left cursor-pointer font-[inherit] text-[length:inherit] [&:hover]:bg-panel [&_>_svg]:shrink-0 [&_>_svg]:text-subtext [&_>_svg.file-tree-chevron]:text-muted artifact-tree-row [&.selected]:bg-panel [&.selected:hover]:bg-panel [&:hover_.ft-row-delete]:opacity-100" style={indent} onClick={() => onToggle(e.path)}>
|
||||
<div key={e.path} className="min-w-0 max-w-full">
|
||||
<div className="file-tree-row flex w-full min-w-0 items-center gap-1.5 py-[3px] px-2.5 border-0 bg-transparent text-text text-left cursor-pointer font-[inherit] text-[length:inherit] [&:hover]:bg-panel [&_>_svg]:shrink-0 [&_>_svg]:text-subtext [&_>_svg.file-tree-chevron]:text-muted artifact-tree-row [&.selected]:bg-panel [&.selected:hover]:bg-panel [&:hover_.ft-row-delete]:opacity-100" style={indent} onClick={() => onToggle(e.path)}>
|
||||
<button
|
||||
className="file-tree-chevron text-muted shrink-0 [button&]:inline-flex [button&]:items-center [button&]:justify-center [button&]:w-[13px] [button&]:h-[13px] [button&]:p-0 [button&]:border-0 [button&]:bg-transparent [button&_>_svg]:transition-transform [button&_>_svg]:duration-120 [button&_>_svg]:ease-standard [button&_>_svg.open]:rotate-90"
|
||||
aria-label={open ? `Collapse ${e.name}` : `Expand ${e.name}`}
|
||||
@@ -400,6 +502,7 @@ function TreeRows({
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
onOpenFile={onOpenFile}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
@@ -411,18 +514,27 @@ function TreeRows({
|
||||
<button
|
||||
key={e.path}
|
||||
type="button"
|
||||
className={`file-tree-row flex items-center gap-1.5 w-full py-[3px] px-2.5 border-0 bg-transparent text-text text-left cursor-pointer font-[inherit] text-[length:inherit] [&:hover]:bg-panel [&_>_svg]:shrink-0 [&_>_svg]:text-subtext [&_>_svg.file-tree-chevron]:text-muted artifact-tree-row [&.selected]:bg-panel [&.selected:hover]:bg-panel [&:hover_.ft-row-delete]:opacity-100 ${selected === e.path ? "selected" : ""}`}
|
||||
className={`file-tree-row flex w-full min-w-0 items-center gap-1.5 py-[3px] px-2.5 border-0 bg-transparent text-text text-left cursor-pointer font-[inherit] text-[length:inherit] [&:hover]:bg-panel [&_>_svg]:shrink-0 [&_>_svg]:text-subtext [&_>_svg.file-tree-chevron]:text-muted artifact-tree-row [&.selected]:bg-panel [&.selected:hover]:bg-panel [&:hover_.ft-row-delete]:opacity-100 ${selected === e.path ? "selected" : ""}`}
|
||||
style={indent}
|
||||
title={e.path}
|
||||
title={`${e.path} — double-click or Command/Ctrl+Enter to open in a tab`}
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
aria-pressed={selected === e.path}
|
||||
onClick={() => onSelect(e.path)}
|
||||
onDoubleClick={() => onOpenFile(e.path)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" || (!event.metaKey && !event.ctrlKey)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelect(e.path);
|
||||
onOpenFile(e.path);
|
||||
}}
|
||||
>
|
||||
<FileTypeIcon name={e.name} />
|
||||
<span className="file-tree-name flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">{e.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -464,11 +576,13 @@ export function ArtifactsTab({
|
||||
project,
|
||||
artifacts,
|
||||
onChanged,
|
||||
onOpenFile,
|
||||
onOpenStorage,
|
||||
}: {
|
||||
project: Project;
|
||||
artifacts: ProjectArtifacts | null;
|
||||
onChanged: () => void;
|
||||
onOpenFile: (path: string) => void;
|
||||
/** Navigate to Settings → Storage (where the data dir can be changed). */
|
||||
onOpenStorage: () => void;
|
||||
}) {
|
||||
@@ -478,6 +592,12 @@ export function ArtifactsTab({
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => initialCollapsed(project.id));
|
||||
const [treeWidth, setTreeWidth] = useState(initialTreeWidth);
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
const projectBriefPath =
|
||||
artifacts?.entries.find((entry) => !entry.isDir && isProjectBriefPath(entry.path))?.path ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected && projectBriefPath) setSelected(projectBriefPath);
|
||||
}, [selected, projectBriefPath]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -560,6 +680,7 @@ export function ArtifactsTab({
|
||||
selected={selected}
|
||||
onToggle={toggle}
|
||||
onSelect={setSelected}
|
||||
onOpenFile={onOpenFile}
|
||||
onDelete={remove}
|
||||
/>
|
||||
);
|
||||
@@ -601,6 +722,7 @@ export function ArtifactsTab({
|
||||
projectId={project.id}
|
||||
entry={selectedEntry}
|
||||
onDelete={remove}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
) : (
|
||||
<div className="fpreview flex-1 min-w-0 flex flex-col min-h-0 bg-background fpreview-none items-center justify-center gap-2 text-md text-muted">
|
||||
|
||||
@@ -3487,7 +3487,7 @@ export function ChatPanel({
|
||||
onOpenPlan,
|
||||
onOpenSubagent,
|
||||
onOpenWorktree,
|
||||
onStartTour,
|
||||
onOpenDemoWelcome,
|
||||
onActiveSessionChange,
|
||||
preferredAgent,
|
||||
onPreferredAgentChange,
|
||||
@@ -3531,8 +3531,8 @@ export function ChatPanel({
|
||||
onOpenSubagent?: (sessionId: string, spawnPartId: string) => void;
|
||||
/** Open the pinned Files home for the active session. */
|
||||
onOpenWorktree: () => void;
|
||||
/** Replay the onboarding tour (chat header help button). */
|
||||
onStartTour?: () => void;
|
||||
/** Reopen the demo welcome modal from the chat header. */
|
||||
onOpenDemoWelcome?: () => void;
|
||||
/** The open chat session, surfaced so the shell can scope panes to it. */
|
||||
onActiveSessionChange?: (sessionId: string | null) => void;
|
||||
/** Database-backed selection used to seed new chat sessions. */
|
||||
@@ -4922,12 +4922,12 @@ export function ChatPanel({
|
||||
"New session"
|
||||
)}
|
||||
</div>
|
||||
{onStartTour && (
|
||||
{onOpenDemoWelcome && (
|
||||
<button
|
||||
className={ICON_BUTTON_CLASS_NAME}
|
||||
data-tip="Replay tour"
|
||||
aria-label="Replay tour"
|
||||
onClick={onStartTour}
|
||||
data-tip="About this demo"
|
||||
aria-label="About this demo"
|
||||
onClick={onOpenDemoWelcome}
|
||||
>
|
||||
<HelpCircle size={15} />
|
||||
</button>
|
||||
@@ -5313,8 +5313,8 @@ export function ChatPanel({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="composer-actions flex justify-end items-center gap-2 pt-1.5 px-2 pb-2">
|
||||
<div className="option-picker relative inline-flex" ref={dataSources.ref}>
|
||||
<div className="composer-actions flex min-w-0 justify-end items-center gap-2 pt-1.5 px-2 pb-2">
|
||||
<div className="option-picker relative inline-flex shrink-0" ref={dataSources.ref}>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-bare inline-flex items-center justify-center rounded-sm p-1.5 text-text transition-[background] duration-150 ease-standard hover:bg-surface"
|
||||
@@ -5346,7 +5346,7 @@ export function ChatPanel({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-attach inline-flex items-center justify-center w-7.5 h-7.5 rounded-sm text-text cursor-pointer transition-[background] duration-150 ease-standard [&:hover]:bg-surface"
|
||||
className="composer-attach inline-flex shrink-0 items-center justify-center w-7.5 h-7.5 rounded-sm text-text cursor-pointer transition-[background] duration-150 ease-standard [&:hover]:bg-surface"
|
||||
title="Attach a PDF or image"
|
||||
aria-label="Attach a PDF or image"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
@@ -5356,7 +5356,7 @@ export function ChatPanel({
|
||||
{planActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="plan-indicator group inline-flex h-7.5 items-center gap-1.5 rounded-sm bg-surface px-2 text-sm text-muted transition-colors hover:text-text focus-visible:text-text"
|
||||
className="plan-indicator group inline-flex h-7.5 shrink-0 items-center gap-1.5 rounded-sm bg-surface px-2 text-sm text-muted transition-colors hover:text-text focus-visible:text-text"
|
||||
title="Exit Plan mode"
|
||||
aria-label="Exit Plan mode"
|
||||
onClick={() => void exitPlanMode()}
|
||||
@@ -5368,7 +5368,7 @@ export function ChatPanel({
|
||||
<span>Plan</span>
|
||||
</button>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
<div className="min-w-0 flex-1" />
|
||||
{/* The model picker reflects the open session (harness locked once it
|
||||
exists); the global default only applies before the first
|
||||
message. */}
|
||||
@@ -5391,12 +5391,12 @@ export function ChatPanel({
|
||||
// (their cards are the affordance; send() can't service them).
|
||||
// Send stays only when it actually works: idle, or a held
|
||||
// QUESTION card that owns typed text.
|
||||
<button className="send-btn inline-flex items-center justify-center w-8 h-8 rounded-md bg-primary text-background transition-[background,opacity] duration-100 ease-standard [&:hover:not(:disabled)]:bg-[color-mix(in_oklab,_var(--primary)_88%,_var(--text))] [&:disabled]:opacity-40 [&:disabled]:cursor-default [&.stop]:bg-surface [&.stop]:text-text [&.stop:hover:not(:disabled)]:bg-[color-mix(in_oklab,_var(--surface)_88%,_var(--text))] stop" title="Stop" aria-label="Stop" onClick={stop}>
|
||||
<button className="send-btn inline-flex shrink-0 items-center justify-center w-8 h-8 rounded-md bg-primary text-background transition-[background,opacity] duration-100 ease-standard [&:hover:not(:disabled)]:bg-[color-mix(in_oklab,_var(--primary)_88%,_var(--text))] [&:disabled]:opacity-40 [&:disabled]:cursor-default [&.stop]:bg-surface [&.stop]:text-text [&.stop:hover:not(:disabled)]:bg-[color-mix(in_oklab,_var(--surface)_88%,_var(--text))] stop" title="Stop" aria-label="Stop" onClick={stop}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="send-btn inline-flex items-center justify-center w-8 h-8 rounded-md bg-primary text-background transition-[background,opacity] duration-100 ease-standard [&:hover:not(:disabled)]:bg-[color-mix(in_oklab,_var(--primary)_88%,_var(--text))] [&:disabled]:opacity-40 [&:disabled]:cursor-default [&.stop]:bg-surface [&.stop]:text-text [&.stop:hover:not(:disabled)]:bg-[color-mix(in_oklab,_var(--surface)_88%,_var(--text))]"
|
||||
className="send-btn inline-flex shrink-0 items-center justify-center w-8 h-8 rounded-md bg-primary text-background transition-[background,opacity] duration-100 ease-standard [&:hover:not(:disabled)]:bg-[color-mix(in_oklab,_var(--primary)_88%,_var(--text))] [&:disabled]:opacity-40 [&:disabled]:cursor-default [&.stop]:bg-surface [&.stop]:text-text [&.stop:hover:not(:disabled)]:bg-[color-mix(in_oklab,_var(--surface)_88%,_var(--text))]"
|
||||
title="Send"
|
||||
aria-label="Send"
|
||||
onClick={() => void send()}
|
||||
|
||||
@@ -31,7 +31,7 @@ function VisibleContextMeter({ usage }: { usage: ContextUsage }) {
|
||||
const fill = pct === null ? "var(--accent)" : tone(pct);
|
||||
|
||||
return (
|
||||
<div className="option-picker relative inline-flex" ref={ref}>
|
||||
<div className="option-picker relative inline-flex shrink-0" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-bare inline-flex items-center gap-[3px] text-md text-text py-[5px] px-1 rounded-sm transition-[background] duration-150 ease-standard [&:hover]:bg-surface [&.context-ring]:inline-flex [&.context-ring]:items-center [&.context-ring]:mr-2 context-ring"
|
||||
|
||||
@@ -46,11 +46,11 @@ export function RailHeader({
|
||||
return (
|
||||
<div className="rail-brand flex items-center gap-1 h-16 p-2 border-b border-b-border shrink-0 [&_.project-switcher]:relative [&_.project-switcher]:flex-1 [&_.project-switcher]:self-stretch [&_.project-switcher]:min-w-0 [&_.project-back]:shrink-0 [&_.brand]:flex [&_.brand]:items-center [&_.brand]:justify-between [&_.brand]:gap-2 [&_.brand]:w-full [&_.brand]:h-full [&_.brand]:min-w-0 [&_.brand]:font-semibold [&_.brand]:text-base [&_.brand]:text-text [&_.brand]:py-1 [&_.brand]:px-1.5 [&_.brand]:border [&_.brand]:border-transparent [&_.brand]:rounded-sm [&_.brand:hover]:bg-surface [&_.brand:hover]:border-border [&_.brand.open]:bg-surface [&_.brand.open]:border-border [&_.brand_svg]:shrink-0 [&_.brand-project-copy]:flex [&_.brand-project-copy]:flex-col [&_.brand-project-copy]:gap-[3px] [&_.brand-project-copy]:min-w-0 [&_.brand-project-copy]:leading-[1.15] [&_.brand-project-copy]:text-left [&_.brand-project-label]:text-muted [&_.brand-project-label]:text-2xs [&_.brand-project-label]:font-medium [&_.brand-project-label]:tracking-[0.04em] [&_.brand-project-label]:uppercase [&_.brand_.brand-project]:min-w-0 [&_.brand_.brand-project]:overflow-hidden [&_.brand_.brand-project]:text-ellipsis [&_.brand_.brand-project]:whitespace-nowrap [&_.brand_.brand-project]:text-2xl [&_.project-chevron]:text-muted [&_.project-chevron]:opacity-0 [&_.project-chevron]:transition-transform [&_.project-chevron]:duration-120 [&_.project-chevron]:ease-standard [&_.brand:hover_.project-chevron]:opacity-100 [&_.brand.open_.project-chevron]:opacity-100 [&_.brand.open_.project-chevron]:rotate-180 [&_.project-menu]:left-0 [&_.project-menu]:w-52.5 [&_.project-menu]:z-70">
|
||||
<button
|
||||
className={`${ICON_BUTTON_CLASS_NAME} project-back`}
|
||||
className={`${ICON_BUTTON_CLASS_NAME} project-back !text-text`}
|
||||
aria-label="All projects"
|
||||
onClick={onHome}
|
||||
>
|
||||
<ArrowLeft size={15} />
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<div className="project-switcher" ref={ref}>
|
||||
<button
|
||||
|
||||
@@ -283,12 +283,12 @@ export function ModelPicker({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="model-picker relative inline-flex" data-onboarding="model-picker" ref={rootRef}>
|
||||
<div className="model-picker relative inline-flex min-w-0" data-onboarding="model-picker" ref={rootRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="composer-pill inline-flex items-center gap-[5px] text-md text-text py-[5px] px-2 rounded-sm whitespace-nowrap transition-[background] duration-150 ease-standard [&:hover]:bg-surface"
|
||||
title="Harness + model for this chat"
|
||||
className="composer-pill inline-flex min-w-0 max-w-full items-center gap-[5px] text-md text-text py-[5px] px-2 rounded-sm whitespace-nowrap transition-[background] duration-150 ease-standard [&:hover]:bg-surface"
|
||||
title={`Harness + model for this chat: ${label}${reasoningLabel ? ` · ${reasoningLabel}` : ""}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
@@ -300,9 +300,11 @@ export function ModelPicker({
|
||||
}}
|
||||
>
|
||||
{value?.harness && <HarnessLogo harness={value.harness} size={14} />}
|
||||
{label}
|
||||
{reasoningLabel && <span className="model-picker-reasoning ml-0.5 text-muted">{reasoningLabel}</span>}
|
||||
<ChevronDown size={14} className="text-muted" />
|
||||
<span className="model-picker-label min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{label}
|
||||
{reasoningLabel && <span className="model-picker-reasoning ml-1 text-muted">{reasoningLabel}</span>}
|
||||
</span>
|
||||
<ChevronDown size={14} className="shrink-0 text-muted" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="model-menu absolute bottom-[calc(100%_+_8px)] left-0 max-h-100 flex flex-col bg-background border border-border rounded-md shadow-[0_10px_26px_rgba(0,_0,_0,_0.16)] z-50 overflow-hidden w-72 [&.align-right]:left-auto [&.align-right]:right-0 [&_input]:rounded-none [&_input]:border-0 [&_input]:border-b [&_input]:border-b-border-variant [&_input]:bg-none [&_input]:bg-transparent [&_input]:py-2 [&_input]:px-2.5 [&_input]:text-sm [&_input]:outline-none align-right">
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../api";
|
||||
import { renderNote } from "./agentNote";
|
||||
import { HarnessLogo } from "./HarnessLogo";
|
||||
import { LIT_SOURCE_NAME, LitSourceLogo, type LitSource } from "./LitSourceLogo";
|
||||
import { onHarnessAuth } from "../events";
|
||||
import { GHOST_BUTTON_CLASS_NAME, MONO_CLASS_NAME, PAPER_TITLE_CLASS_NAME, PRIMARY_BUTTON_CLASS_NAME, SPINNER_CLASS_NAME, STATUS_BADGE_CLASS_NAME } from "../styleClasses";
|
||||
|
||||
@@ -65,7 +66,7 @@ export function Onboarding({
|
||||
onDone: (project: Project, selection: AgentSelection) => void;
|
||||
preferredAgent: AgentSelection | null;
|
||||
}) {
|
||||
const [step, setStep] = useState<0 | 1>(0);
|
||||
const [step, setStep] = useState<0 | 1 | 2>(0);
|
||||
const [harnesses, setHarnesses] = useState<Harness[] | null>(null);
|
||||
const [gitVersion, setGitVersion] = useState<string | null>();
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
@@ -226,15 +227,85 @@ export function Onboarding({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable_both-edges] bg-canvas onboarding [&_.home-inner]:max-w-140 [&_.home-inner]:pt-24">
|
||||
<div className="home-inner max-w-155 my-0 mx-auto pt-12 px-6 pb-16">
|
||||
<div
|
||||
className={`home flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable_both-edges] bg-canvas onboarding ${
|
||||
step === 0
|
||||
? "[&_.home-inner]:max-w-300 [&_.home-inner]:pt-0 [&_.home-inner]:pb-0"
|
||||
: "[&_.home-inner]:max-w-140 [&_.home-inner]:pt-24"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`home-inner max-w-155 my-0 mx-auto ${
|
||||
step === 0 ? "px-8 sm:px-12" : "pt-12 px-6 pb-16"
|
||||
}`}
|
||||
>
|
||||
{step === 0 ? (
|
||||
<div className="onb-intro relative flex min-h-dvh flex-col justify-center gap-4 py-12 min-[1120px]:grid min-[1120px]:grid-cols-[minmax(0,_1.1fr)_minmax(28rem,_1fr)] min-[1120px]:grid-rows-[auto_auto] min-[1120px]:content-center min-[1120px]:gap-x-20 min-[1120px]:gap-y-10">
|
||||
<div className="onb-intro-copy relative z-10 min-[1120px]:col-start-1 min-[1120px]:row-start-1 min-[1120px]:self-start">
|
||||
<div className="onb-intro-brand text-[4rem] leading-none font-semibold tracking-[-0.035em] mb-10">
|
||||
<Wordmark />
|
||||
</div>
|
||||
<h2 className="onb-title mt-0 mx-0 text-[2.5rem] leading-[1.08] tracking-[-0.035em]">
|
||||
A workspace for your research agents
|
||||
</h2>
|
||||
</div>
|
||||
<div className="onb-intro-features relative min-[1120px]:col-start-2 min-[1120px]:row-start-1 min-[1120px]:self-end">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute -inset-14 rounded-full bg-primary-subtle opacity-70 blur-3xl"
|
||||
/>
|
||||
<ul className="onb-intro-list relative flex flex-col gap-4 m-0 p-0 list-none">
|
||||
<li className="rounded-2xl border border-border bg-background p-6 shadow-[0_14px_36px_color-mix(in_oklab,_var(--text)_6%,_transparent)]">
|
||||
<span>
|
||||
<strong className="mb-1.5 block text-2xl tracking-[-0.015em]">
|
||||
Consolidate your research
|
||||
</strong>
|
||||
<span className="block text-lg leading-[1.55] text-text">
|
||||
Track experiments, artifacts, compute, skills, and code all in one place.
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
<li className="rounded-2xl border border-border bg-background p-6 shadow-[0_14px_36px_color-mix(in_oklab,_var(--text)_6%,_transparent)]">
|
||||
<span>
|
||||
<strong className="mb-1.5 block text-2xl tracking-[-0.015em]">
|
||||
Ground your agents
|
||||
</strong>
|
||||
<span className="block text-lg leading-[1.55] text-text">
|
||||
Connected to <InlineLitSource source="alphaxiv" />,{" "}
|
||||
<InlineLitSource source="biorxiv" />, and{" "}
|
||||
<InlineLitSource source="openalex" />{" "}
|
||||
to ground your agents in the latest research.
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
<li className="rounded-2xl border border-border bg-background p-6 shadow-[0_14px_36px_color-mix(in_oklab,_var(--text)_6%,_transparent)]">
|
||||
<span>
|
||||
<strong className="mb-1.5 block text-2xl tracking-[-0.015em]">
|
||||
Everything stays local
|
||||
</strong>
|
||||
<span className="block text-lg leading-[1.55] text-text">
|
||||
Your code, data, and experiment history stay on your machine.
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="onb-intro-actions relative z-10 mt-8 flex justify-end min-[1120px]:col-start-2 min-[1120px]:row-start-2 min-[1120px]:mt-0 min-[1120px]:self-start">
|
||||
<button
|
||||
className={`${PRIMARY_BUTTON_CLASS_NAME} !py-3.5 !px-7 !text-xl !rounded-lg`}
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
Continue <ArrowRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : step === 1 ? (
|
||||
<>
|
||||
<div className="onb-eyebrow text-xl font-semibold text-muted mb-4.5">
|
||||
<Wordmark /> · Step 1 of 2
|
||||
</div>
|
||||
<h2 className="onb-title mt-0 mx-0 mb-1.5 text-3xl tracking-[-0.01em]">Choose a coding agent</h2>
|
||||
<p className="onb-sub text-text text-base leading-[1.55] mt-0 mx-0 mb-5.5 max-w-120">OpenResearch uses an agent already signed in on this machine.</p>
|
||||
<p className="onb-sub text-text text-base leading-[1.55] mt-0 mx-0 mb-5.5 max-w-120">OpenResearch uses a coding agent already installed on this machine.</p>
|
||||
{harnesses !== null && !anyAgentReady && (
|
||||
<p className={ONB_GATE_HINT_CLASS_NAME}>
|
||||
Sign in to at least one agent to continue.
|
||||
@@ -277,6 +348,9 @@ export function Onboarding({
|
||||
</div>
|
||||
)}
|
||||
<div className="onb-actions flex items-center gap-2.5 mt-5.5">
|
||||
<button className={GHOST_BUTTON_CLASS_NAME} onClick={() => setStep(0)}>
|
||||
<ArrowLeft size={12} /> Back
|
||||
</button>
|
||||
{(harnessError ||
|
||||
gitError ||
|
||||
gitVersion === null ||
|
||||
@@ -288,7 +362,7 @@ export function Onboarding({
|
||||
<div style={{ flex: 1 }} />
|
||||
<button
|
||||
className={PRIMARY_BUTTON_CLASS_NAME}
|
||||
onClick={() => setStep(1)}
|
||||
onClick={() => setStep(2)}
|
||||
disabled={checking || !anyAgentReady || preferredHarness === null || !gitReady}
|
||||
title={
|
||||
checking
|
||||
@@ -418,7 +492,7 @@ export function Onboarding({
|
||||
</p>
|
||||
)}
|
||||
<div className="onb-actions flex items-center gap-2.5 mt-5.5">
|
||||
<button className={GHOST_BUTTON_CLASS_NAME} onClick={() => setStep(0)} disabled={finishing}>
|
||||
<button className={GHOST_BUTTON_CLASS_NAME} onClick={() => setStep(1)} disabled={finishing}>
|
||||
<ArrowLeft size={12} /> Back
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
@@ -451,6 +525,20 @@ export function Onboarding({
|
||||
);
|
||||
}
|
||||
|
||||
function InlineLitSource({ source }: { source: LitSource }) {
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
<LitSourceLogo
|
||||
source={source}
|
||||
size={18}
|
||||
decorative
|
||||
className="mr-1 inline-flex align-[-0.15em] !rounded-none !bg-transparent !p-0 !shadow-none"
|
||||
/>
|
||||
{LIT_SOURCE_NAME[source]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fast-search titles carry scrape cruft: "[1706.03762] Title - arXiv".
|
||||
* Kept in sync with NewProjectForm's cleanTitle. */
|
||||
function cleanPaperTitle(title: string): string {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { GitHubMark } from "./BackendLogos";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { deleteProject, timeAgo, type Project } from "../api";
|
||||
import { NewProjectForm } from "./NewProjectForm";
|
||||
import { MONO_CLASS_NAME, SMALL_BUTTON_CLASS_NAME } from "../styleClasses";
|
||||
import { BUTTON_CLASS_NAME, MONO_CLASS_NAME } from "../styleClasses";
|
||||
|
||||
export function NewProjectDialog({
|
||||
onClose,
|
||||
@@ -71,14 +71,14 @@ export function NewProjectDialog({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-backdrop fixed inset-0 bg-[rgba(29,_27,_26,_0.4)] flex items-start justify-center pt-[var(--modal-top)] px-4 pb-6 overflow-y-auto z-100"
|
||||
className="modal-backdrop fixed inset-0 bg-[rgba(29,_27,_26,_0.42)] flex items-center justify-center p-5 overflow-y-auto z-100"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="modal w-120 max-w-[94vw] max-h-[calc(100vh_-_var(--modal-top)_-_48px)] overflow-y-auto bg-background border border-border rounded-xl shadow-[0_24px_60px_rgba(0,_0,_0,_0.22)] p-6 [&_h2]:mt-0 [&_h2]:mx-0 [&_h2]:mb-3.5 [&_h2]:text-xl"
|
||||
className="modal w-120 max-w-full max-h-[calc(100vh_-_40px)] overflow-y-auto bg-background border border-border rounded-xl shadow-[0_24px_60px_rgba(0,_0,_0,_0.22)] p-6 [&_h2]:mt-0 [&_h2]:mx-0 [&_h2]:mb-3.5 [&_h2]:text-xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="new-project-dialog-title"
|
||||
@@ -131,8 +131,11 @@ export function ProjectsHome({
|
||||
</div>
|
||||
<div className="home-head flex items-center justify-between gap-3 mb-4.5 [&_h2]:m-0 [&_h2]:text-2xl [&_h2]:tracking-[-0.01em]">
|
||||
<h2>Projects</h2>
|
||||
<button className={SMALL_BUTTON_CLASS_NAME} onClick={() => setModalOpen(true)}>
|
||||
<Plus size={13} /> New project
|
||||
<button
|
||||
className={BUTTON_CLASS_NAME}
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
<Plus size={15} /> New project
|
||||
</button>
|
||||
</div>
|
||||
<div className="home-list flex flex-col gap-2.5">
|
||||
|
||||
+139
-227
@@ -1,242 +1,154 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
type Bounds,
|
||||
type TourAnchor,
|
||||
useMeasure,
|
||||
usePopoverPosition,
|
||||
useTourBounds,
|
||||
} from "./tourGeometry";
|
||||
import { GHOST_BUTTON_CLASS_NAME, ICON_BUTTON_CLASS_NAME, PRIMARY_BUTTON_CLASS_NAME } from "../styleClasses";
|
||||
BUTTON_CLASS_NAME,
|
||||
ICON_BUTTON_CLASS_NAME,
|
||||
PRIMARY_BUTTON_CLASS_NAME,
|
||||
} from "../styleClasses";
|
||||
import { BrandMark } from "./Wordmark";
|
||||
|
||||
/** Breathing room between a target's edges and the spotlight cutout. */
|
||||
const BOX_PADDING = 8;
|
||||
/** Gap between the spotlight and the tour card. */
|
||||
const CARD_DISTANCE = 20;
|
||||
export function DemoWelcomeModal({
|
||||
onClose,
|
||||
onCreateProject,
|
||||
}: {
|
||||
onClose: () => Promise<void>;
|
||||
onCreateProject: () => Promise<void>;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
interface TourStep {
|
||||
/** data-onboarding ids to spotlight; null = centered card over a full dim. */
|
||||
focus: string[] | null;
|
||||
/** Which side of the target the card sits on; null = centered. */
|
||||
anchor: TourAnchor | null;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
const run = useCallback(
|
||||
(action: () => Promise<void>) => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
void action()
|
||||
.catch(() => setError("Couldn't save your progress. Try again."))
|
||||
.finally(() => setSaving(false));
|
||||
},
|
||||
[saving],
|
||||
);
|
||||
|
||||
const STEPS: TourStep[] = [
|
||||
{
|
||||
focus: null,
|
||||
anchor: null,
|
||||
title: "Welcome to OpenResearch",
|
||||
description:
|
||||
"OpenResearch is your home for autoresearch. Spawn and coordinate research agents " +
|
||||
"in one workspace.",
|
||||
},
|
||||
{
|
||||
focus: ["composer"],
|
||||
anchor: "above",
|
||||
title: "Talk to your research agent",
|
||||
description:
|
||||
"Prompt your research agents to replicate a paper, create a baseline experiment, " +
|
||||
"run an eval, or investigate any research question. Type / for commands like " +
|
||||
"/reproduce-paper.",
|
||||
},
|
||||
{
|
||||
focus: ["model-picker"],
|
||||
anchor: "above",
|
||||
title: "Pick your model",
|
||||
description:
|
||||
"Choose a model from any harness you've connected: Claude Code, Codex, or OpenCode. " +
|
||||
"New sessions start with whatever you pick here, and each session keeps its harness.",
|
||||
},
|
||||
{
|
||||
focus: ["nav-artifacts"],
|
||||
anchor: "right",
|
||||
title: "Project artifacts",
|
||||
description:
|
||||
"The agent writes its reports, figures, and other outputs here, and anything you drop " +
|
||||
"in is visible to it too. Check Artifacts after a run to see what came back.",
|
||||
},
|
||||
{
|
||||
focus: ["nav-compute"],
|
||||
anchor: "right",
|
||||
title: "Configure compute",
|
||||
description:
|
||||
"This is where compute is configured. Point runs at this machine, Modal, SSH boxes, " +
|
||||
"Kubernetes, or Slurm. Set it up once and agents pick the right hardware per run.",
|
||||
},
|
||||
{
|
||||
focus: ["experiments"],
|
||||
anchor: "left",
|
||||
title: "Follow every experiment",
|
||||
description:
|
||||
"Runs land here as a tree of experiments. Branch variants off a baseline, compare " +
|
||||
"results, and open any run's terminal or code changes in a tab.",
|
||||
},
|
||||
{
|
||||
focus: ["new-session"],
|
||||
anchor: "right",
|
||||
title: "Start a session",
|
||||
description:
|
||||
"Each session is its own agent working in its own worktree, so you can run several " +
|
||||
"agents in parallel. Ask for your first experiment whenever you're ready.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The onboarding tour: a dimming overlay with a spotlight cut around the
|
||||
* focused element, plus an anchored card describing it. CSS transitions morph
|
||||
* the spotlight between steps. Targets are located by `data-onboarding`
|
||||
* attributes; a missing target degrades to a full dim with a centered card.
|
||||
*/
|
||||
export function Tour({ onClose }: { onClose: () => Promise<void> }) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [closeError, setCloseError] = useState<string | null>(null);
|
||||
const step = STEPS[index];
|
||||
const bounds = useTourBounds(step.focus ?? []);
|
||||
const requestClose = useCallback(() => {
|
||||
if (closing) return;
|
||||
setClosing(true);
|
||||
setCloseError(null);
|
||||
void onClose()
|
||||
.catch((error) => {
|
||||
setCloseError(error instanceof Error ? error.message : String(error));
|
||||
})
|
||||
.finally(() => setClosing(false));
|
||||
}, [closing, onClose]);
|
||||
|
||||
// Own Escape in the capture phase so it can never reach ChatPanel's
|
||||
// document-level listener, which would interrupt a running agent turn.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
requestClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => document.removeEventListener("keydown", onKey, true);
|
||||
}, [requestClose]);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
run(onClose);
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||
}, [onClose, run]);
|
||||
|
||||
const box = bounds
|
||||
? {
|
||||
left: bounds.x - BOX_PADDING,
|
||||
top: bounds.y - BOX_PADDING,
|
||||
width: bounds.width + BOX_PADDING * 2,
|
||||
height: bounds.height + BOX_PADDING * 2,
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const focusable = () =>
|
||||
[...dialog.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
|
||||
)];
|
||||
(focusable()[0] ?? dialog).focus();
|
||||
|
||||
const trapFocus = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Tab") return;
|
||||
const controls = focusable();
|
||||
if (controls.length === 0) {
|
||||
event.preventDefault();
|
||||
dialog.focus();
|
||||
return;
|
||||
}
|
||||
: null;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", trapFocus, true);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", trapFocus, true);
|
||||
previousFocus?.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return createPortal(
|
||||
<div className="tour-overlay fixed inset-0 z-200">
|
||||
{box ? (
|
||||
<>
|
||||
{/* Dim everything except the spotlight via an oversized box-shadow. */}
|
||||
<div className="tour-spotlight absolute rounded-lg shadow-[0_0_0_9999px_rgba(0,_0,_0,_0.55)] transition-all duration-300 ease-standard" style={box} />
|
||||
<div className="tour-ring absolute rounded-lg border-2 border-primary transition-all duration-300 ease-standard" style={box} />
|
||||
</>
|
||||
) : (
|
||||
<div className="tour-dim absolute inset-0 bg-[rgba(0,_0,_0,_0.55)]" />
|
||||
)}
|
||||
<TourCard
|
||||
step={step}
|
||||
bounds={bounds}
|
||||
index={index}
|
||||
onBack={() => setIndex((i) => Math.max(0, i - 1))}
|
||||
onNext={() => (index + 1 >= STEPS.length ? requestClose() : setIndex(index + 1))}
|
||||
onClose={requestClose}
|
||||
closing={closing}
|
||||
closeError={closeError}
|
||||
/>
|
||||
<div className="fixed inset-0 z-200 flex items-center justify-center bg-[rgba(29,_27,_26,_0.42)] p-5">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="relative w-110 max-w-full rounded-xl border border-border bg-background p-6 shadow-[0_24px_60px_rgba(0,_0,_0,_0.22)]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="demo-welcome-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<button
|
||||
className={`${ICON_BUTTON_CLASS_NAME} !absolute top-3.5 right-3.5`}
|
||||
aria-label="Close"
|
||||
onClick={() => run(onClose)}
|
||||
disabled={saving}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<div className="mb-5 flex items-center gap-3 pr-8">
|
||||
<span className="block h-9 w-9 shrink-0 [&_svg]:block [&_svg]:h-full [&_svg]:w-full">
|
||||
<BrandMark />
|
||||
</span>
|
||||
<div>
|
||||
<div className="mb-0.5 text-xs font-semibold tracking-[0.08em] text-primary uppercase">
|
||||
Demo project
|
||||
</div>
|
||||
<h2
|
||||
id="demo-welcome-title"
|
||||
className="m-0 text-3xl leading-tight tracking-[-0.02em]"
|
||||
>
|
||||
Welcome to OpenResearch
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-base leading-relaxed text-text [&_p]:m-0 [&_p_+_p]:mt-3">
|
||||
<p>
|
||||
This is a demo project showing how OpenResearch works. This demo uses Andrej
|
||||
Karpathy's{" "}
|
||||
<a
|
||||
href="https://github.com/karpathy/nanochat"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-semibold text-primary underline decoration-border-strong underline-offset-3 hover:decoration-primary"
|
||||
>
|
||||
nanochat
|
||||
</a>, a repo for training a mini-GPT from scratch.
|
||||
</p>
|
||||
<p>
|
||||
Look through the agent conversations, experiments, runs, and artifacts to see how a
|
||||
project on OpenResearch comes together.
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="mt-3 mb-0 text-sm text-accent-red">{error}</p>}
|
||||
<div className="mt-6 flex flex-wrap items-center justify-end gap-2.5">
|
||||
<button
|
||||
className={BUTTON_CLASS_NAME}
|
||||
onClick={() => run(onCreateProject)}
|
||||
disabled={saving}
|
||||
>
|
||||
Create a new project
|
||||
</button>
|
||||
<button
|
||||
className={PRIMARY_BUTTON_CLASS_NAME}
|
||||
onClick={() => run(onClose)}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Explore the demo"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function TourCard({
|
||||
step,
|
||||
bounds,
|
||||
index,
|
||||
onBack,
|
||||
onNext,
|
||||
onClose,
|
||||
closing,
|
||||
closeError,
|
||||
}: {
|
||||
step: TourStep;
|
||||
bounds: Bounds;
|
||||
index: number;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
onClose: () => void;
|
||||
closing: boolean;
|
||||
closeError: string | null;
|
||||
}) {
|
||||
const measure = useMeasure();
|
||||
const popover = usePopoverPosition(
|
||||
bounds && step.anchor
|
||||
? {
|
||||
x: bounds.x - BOX_PADDING,
|
||||
y: bounds.y - BOX_PADDING,
|
||||
width: bounds.width + BOX_PADDING * 2,
|
||||
height: bounds.height + BOX_PADDING * 2,
|
||||
anchor: step.anchor,
|
||||
distance: CARD_DISTANCE,
|
||||
}
|
||||
: null,
|
||||
measure,
|
||||
);
|
||||
|
||||
// Only trust the computed position once the card has real dimensions and
|
||||
// the viewport size is known; until then, center it.
|
||||
const positioned = step.anchor != null && bounds != null && popover.x > 0;
|
||||
const last = index + 1 === STEPS.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={measure.ref}
|
||||
className={`tour-card absolute w-97.5 max-w-[calc(100vw_-_32px)] bg-background border border-border rounded-xl shadow-[0_24px_60px_rgba(0,_0,_0,_0.22)] pt-[21px] px-[23px] pb-0 transition-[left,top] duration-200 ease-standard [&.centered]:left-1/2 [&.centered]:top-1/2 [&.centered]:-translate-x-1/2 [&.centered]:-translate-y-1/2 [&.centered]:transition-none [&_h3]:mt-0 [&_h3]:mr-6.5 [&_h3]:mb-[7px] [&_h3]:ml-0 [&_h3]:text-lg [&_h3]:font-semibold [&_p]:m-0 [&_p]:text-base [&_p]:leading-[1.55] [&_p]:text-text [&_.tour-close]:absolute [&_.tour-close]:top-3 [&_.tour-close]:right-3 ${positioned ? "" : "centered"}`}
|
||||
style={positioned ? { left: popover.x, top: popover.y } : undefined}
|
||||
>
|
||||
{positioned && step.anchor && (
|
||||
<Arrow anchor={step.anchor} adjustment={popover.arrowAdjustment} />
|
||||
)}
|
||||
<button className={`${ICON_BUTTON_CLASS_NAME} tour-close`} title="Skip tour" onClick={onClose} disabled={closing}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
<h3>{step.title}</h3>
|
||||
<p>{step.description}</p>
|
||||
{closeError && <div className="error">Couldn't save tour progress. Try again.</div>}
|
||||
<div className="tour-footer flex items-center border-t border-t-border-variant mt-4 py-[11px] px-0">
|
||||
<div className="tour-footer-side flex-1 flex [&.end]:justify-end">
|
||||
{index > 0 && (
|
||||
<button className={GHOST_BUTTON_CLASS_NAME} onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="tour-count text-sm text-muted tabular-nums">
|
||||
{index + 1} / {STEPS.length}
|
||||
</span>
|
||||
<div className="tour-footer-side flex-1 flex [&.end]:justify-end end">
|
||||
<button className={PRIMARY_BUTTON_CLASS_NAME} onClick={onNext} disabled={closing}>
|
||||
{closing ? "Saving…" : last ? "Done" : "Next"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rotated-square arrow on the card edge nearest the spotlight. `adjustment`
|
||||
* is how far viewport clamping displaced the card along its cross axis; the
|
||||
* arrow shifts by the same amount to keep pointing at the target.
|
||||
*/
|
||||
function Arrow({ anchor, adjustment }: { anchor: TourAnchor; adjustment: number }) {
|
||||
const cross =
|
||||
anchor === "above" || anchor === "below" ? `${adjustment}px 0` : `0 ${adjustment}px`;
|
||||
return <div className={`tour-arrow absolute w-3 h-3 bg-background rotate-45 pointer-events-none [&.above]:-bottom-[6.5px] [&.above]:left-[calc(50%_-_6px)] [&.above]:border-r [&.above]:border-r-border [&.above]:border-b [&.above]:border-b-border [&.below]:-top-[6.5px] [&.below]:left-[calc(50%_-_6px)] [&.below]:border-l [&.below]:border-l-border [&.below]:border-t [&.below]:border-t-border [&.left]:-right-[6.5px] [&.left]:top-[calc(50%_-_6px)] [&.left]:border-t [&.left]:border-t-border [&.left]:border-r [&.left]:border-r-border [&.right]:-left-[6.5px] [&.right]:top-[calc(50%_-_6px)] [&.right]:border-b [&.right]:border-b-border [&.right]:border-l [&.right]:border-l-border ${anchor}`} style={{ translate: cross }} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user