refactor(forge): complete gsd → forge rebrand across native, logging, and build system
- Rename native Rust crates: gsd-engine → forge-engine, gsd-ast → forge-ast, gsd-grep → forge-grep - Update all crate dependencies (Cargo.toml, .rs source) and N-API artifacts - Mass rename log prefix [gsd] → [forge] across 81 files (scripts, src/, extensions, tests) - Rename log prefix "gsd-db:" → "forge-db:" in template literals - Update nix flake: add sf-run-native devShell with Rust toolchain for native addon builds - Update CI workflow artifact names (build-native.yml) - Verify only packages/native/* touched (no upstream pi-* packages renamed) Rationale: Complete gsd-2 → singularity-forge rebrand (2026-04-15). Native addon is sf-run-specific; all gsd-prefixed logging and crate names must align with new identity. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e5d655bdb3
commit
172753c3b2
119 changed files with 27638 additions and 280 deletions
0
.codex
Normal file
0
.codex
Normal file
10
.github/workflows/build-native.yml
vendored
10
.github/workflows/build-native.yml
vendored
|
|
@ -77,8 +77,8 @@ jobs:
|
||||||
if: runner.os != 'Windows'
|
if: runner.os != 'Windows'
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts
|
||||||
cp native/target/${{ matrix.target }}/release/libgsd_engine.dylib artifacts/gsd_engine.node 2>/dev/null || \
|
cp native/target/${{ matrix.target }}/release/libforge_engine.dylib artifacts/forge_engine.node 2>/dev/null || \
|
||||||
cp native/target/${{ matrix.target }}/release/libgsd_engine.so artifacts/gsd_engine.node 2>/dev/null || \
|
cp native/target/${{ matrix.target }}/release/libforge_engine.so artifacts/forge_engine.node 2>/dev/null || \
|
||||||
{ echo "::error::No library found for ${{ matrix.platform }}"; exit 1; }
|
{ echo "::error::No library found for ${{ matrix.platform }}"; exit 1; }
|
||||||
ls -la artifacts/
|
ls -la artifacts/
|
||||||
|
|
||||||
|
|
@ -86,13 +86,13 @@ jobs:
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
run: |
|
run: |
|
||||||
mkdir artifacts
|
mkdir artifacts
|
||||||
copy native\target\${{ matrix.target }}\release\gsd_engine.dll artifacts\gsd_engine.node
|
copy native\target\${{ matrix.target }}\release\forge_engine.dll artifacts\forge_engine.node
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: native-${{ matrix.platform }}
|
name: native-${{ matrix.platform }}
|
||||||
path: artifacts/gsd_engine.node
|
path: artifacts/forge_engine.node
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
|
|
@ -118,7 +118,7 @@ jobs:
|
||||||
- name: Copy binaries to platform packages
|
- name: Copy binaries to platform packages
|
||||||
run: |
|
run: |
|
||||||
for platform in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu win32-x64-msvc; do
|
for platform in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu win32-x64-msvc; do
|
||||||
cp "artifacts/native-${platform}/gsd_engine.node" "native/npm/${platform}/gsd_engine.node"
|
cp "artifacts/native-${platform}/forge_engine.node" "native/npm/${platform}/forge_engine.node"
|
||||||
echo "Copied binary for ${platform}"
|
echo "Copied binary for ${platform}"
|
||||||
ls -la "native/npm/${platform}/"
|
ls -la "native/npm/${platform}/"
|
||||||
done
|
done
|
||||||
|
|
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1776067740,
|
||||||
|
"narHash": "sha256-B35lpsqnSZwn1Lmz06BpwF7atPgFmUgw1l8KAV3zpVQ=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "7e495b747b51f95ae15e74377c5ce1fe69c1765f",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-25.11",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
|
|
@ -18,8 +18,13 @@
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
bash
|
bash
|
||||||
bun
|
bun
|
||||||
|
cargo
|
||||||
|
clippy
|
||||||
git
|
git
|
||||||
nodejs_24
|
nodejs_24
|
||||||
|
rust-analyzer
|
||||||
|
rustc
|
||||||
|
rustfmt
|
||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
|
@ -28,7 +33,9 @@
|
||||||
|
|
||||||
echo "gsd-2 runtime shell"
|
echo "gsd-2 runtime shell"
|
||||||
echo " bun : $(command -v bun)"
|
echo " bun : $(command -v bun)"
|
||||||
|
echo " cargo: $(command -v cargo)"
|
||||||
echo " node: $(command -v node)"
|
echo " node: $(command -v node)"
|
||||||
|
echo " rustc: $(command -v rustc)"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[package]
|
[package]
|
||||||
name = "gsd-ast"
|
name = "forge-ast"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -383,7 +383,7 @@ mod tests {
|
||||||
impl Drop for TempTree { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } }
|
impl Drop for TempTree { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } }
|
||||||
fn make_temp_tree() -> TempTree {
|
fn make_temp_tree() -> TempTree {
|
||||||
let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||||
let root = std::env::temp_dir().join(format!("gsd-ast-test-{unique}"));
|
let root = std::env::temp_dir().join(format!("forge-ast-test-{unique}"));
|
||||||
fs::create_dir_all(root.join("nested")).unwrap();
|
fs::create_dir_all(root.join("nested")).unwrap();
|
||||||
fs::write(root.join("a.ts"), "const a = 1;\n").unwrap();
|
fs::write(root.join("a.ts"), "const a = 1;\n").unwrap();
|
||||||
fs::write(root.join("nested").join("b.ts"), "const b = 2;\n").unwrap();
|
fs::write(root.join("nested").join("b.ts"), "const b = 2;\n").unwrap();
|
||||||
|
|
@ -421,7 +421,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_mixed_replace_lang() {
|
fn rejects_mixed_replace_lang() {
|
||||||
let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||||
let root = std::env::temp_dir().join(format!("gsd-ast-mixed-{unique}"));
|
let root = std::env::temp_dir().join(format!("forge-ast-mixed-{unique}"));
|
||||||
fs::create_dir_all(&root).unwrap();
|
fs::create_dir_all(&root).unwrap();
|
||||||
fs::write(root.join("a.ts"), "const a = 1;\n").unwrap();
|
fs::write(root.join("a.ts"), "const a = 1;\n").unwrap();
|
||||||
fs::write(root.join("b.rs"), "fn main() {}\n").unwrap();
|
fs::write(root.join("b.rs"), "fn main() {}\n").unwrap();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[package]
|
[package]
|
||||||
name = "gsd-engine"
|
name = "forge-engine"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
@ -13,8 +13,8 @@ test = false
|
||||||
doctest = false
|
doctest = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gsd-ast = { path = "../ast" }
|
forge-ast = { path = "../ast" }
|
||||||
gsd-grep = { path = "../grep" }
|
forge-grep = { path = "../grep" }
|
||||||
arboard = "3"
|
arboard = "3"
|
||||||
dashmap = "6"
|
dashmap = "6"
|
||||||
globset = "0.4"
|
globset = "0.4"
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
//! Forces the linker to include gsd_ast napi registrations.
|
//! Forces the linker to include forge_ast napi registrations.
|
||||||
use gsd_ast as _;
|
use forge_ast as _;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
//! N-API bindings for the grep module.
|
//! N-API bindings for the grep module.
|
||||||
//!
|
//!
|
||||||
//! Wraps `gsd_grep` functions and exposes them as JS-callable N-API exports.
|
//! Wraps `forge_grep` functions and exposes them as JS-callable N-API exports.
|
||||||
|
|
||||||
use napi::bindgen_prelude::*;
|
use napi::bindgen_prelude::*;
|
||||||
use napi_derive::napi;
|
use napi_derive::napi;
|
||||||
|
|
||||||
use crate::task;
|
use crate::task;
|
||||||
|
|
||||||
// ── N-API types (mirroring gsd_grep types for the JS boundary) ────────
|
// ── N-API types (mirroring forge_grep types for the JS boundary) ────────
|
||||||
|
|
||||||
#[napi(object)]
|
#[napi(object)]
|
||||||
pub struct NapiContextLine {
|
pub struct NapiContextLine {
|
||||||
|
|
@ -105,14 +105,14 @@ fn clamp_u32(value: u64) -> u32 {
|
||||||
value.min(u32::MAX as u64) as u32
|
value.min(u32::MAX as u64) as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_context_line(cl: gsd_grep::ContextLine) -> NapiContextLine {
|
fn convert_context_line(cl: forge_grep::ContextLine) -> NapiContextLine {
|
||||||
NapiContextLine {
|
NapiContextLine {
|
||||||
line_number: clamp_u32(cl.line_number),
|
line_number: clamp_u32(cl.line_number),
|
||||||
line: cl.line,
|
line: cl.line,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_search_match(m: gsd_grep::SearchMatch) -> NapiSearchMatch {
|
fn convert_search_match(m: forge_grep::SearchMatch) -> NapiSearchMatch {
|
||||||
NapiSearchMatch {
|
NapiSearchMatch {
|
||||||
line_number: clamp_u32(m.line_number),
|
line_number: clamp_u32(m.line_number),
|
||||||
line: m.line,
|
line: m.line,
|
||||||
|
|
@ -130,7 +130,7 @@ fn convert_search_match(m: gsd_grep::SearchMatch) -> NapiSearchMatch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_file_match(m: gsd_grep::FileMatch) -> NapiGrepMatch {
|
fn convert_file_match(m: forge_grep::FileMatch) -> NapiGrepMatch {
|
||||||
NapiGrepMatch {
|
NapiGrepMatch {
|
||||||
path: m.path,
|
path: m.path,
|
||||||
line_number: clamp_u32(m.line_number),
|
line_number: clamp_u32(m.line_number),
|
||||||
|
|
@ -157,7 +157,7 @@ fn convert_file_match(m: gsd_grep::FileMatch) -> NapiGrepMatch {
|
||||||
/// and optional context lines.
|
/// and optional context lines.
|
||||||
#[napi(js_name = "search")]
|
#[napi(js_name = "search")]
|
||||||
pub fn search(content: Buffer, options: NapiSearchOptions) -> Result<NapiSearchResult> {
|
pub fn search(content: Buffer, options: NapiSearchOptions) -> Result<NapiSearchResult> {
|
||||||
let opts = gsd_grep::SearchOptions {
|
let opts = forge_grep::SearchOptions {
|
||||||
pattern: options.pattern,
|
pattern: options.pattern,
|
||||||
ignore_case: options.ignore_case.unwrap_or(false),
|
ignore_case: options.ignore_case.unwrap_or(false),
|
||||||
multiline: options.multiline.unwrap_or(false),
|
multiline: options.multiline.unwrap_or(false),
|
||||||
|
|
@ -167,7 +167,7 @@ pub fn search(content: Buffer, options: NapiSearchOptions) -> Result<NapiSearchR
|
||||||
max_columns: options.max_columns.map(|v| v as usize),
|
max_columns: options.max_columns.map(|v| v as usize),
|
||||||
};
|
};
|
||||||
|
|
||||||
match gsd_grep::search_content(content.as_ref(), &opts) {
|
match forge_grep::search_content(content.as_ref(), &opts) {
|
||||||
Ok(result) => Ok(NapiSearchResult {
|
Ok(result) => Ok(NapiSearchResult {
|
||||||
matches: result
|
matches: result
|
||||||
.matches
|
.matches
|
||||||
|
|
@ -188,7 +188,7 @@ pub fn search(content: Buffer, options: NapiSearchOptions) -> Result<NapiSearchR
|
||||||
#[napi(js_name = "grep")]
|
#[napi(js_name = "grep")]
|
||||||
pub fn grep(options: NapiGrepOptions) -> task::Async<NapiGrepResult> {
|
pub fn grep(options: NapiGrepOptions) -> task::Async<NapiGrepResult> {
|
||||||
task::blocking("grep", (), move |_ct| {
|
task::blocking("grep", (), move |_ct| {
|
||||||
let opts = gsd_grep::GrepOptions {
|
let opts = forge_grep::GrepOptions {
|
||||||
pattern: options.pattern,
|
pattern: options.pattern,
|
||||||
path: options.path,
|
path: options.path,
|
||||||
glob: options.glob,
|
glob: options.glob,
|
||||||
|
|
@ -202,7 +202,7 @@ pub fn grep(options: NapiGrepOptions) -> task::Async<NapiGrepResult> {
|
||||||
max_columns: options.max_columns.map(|v| v as usize),
|
max_columns: options.max_columns.map(|v| v as usize),
|
||||||
};
|
};
|
||||||
|
|
||||||
match gsd_grep::search_path(&opts) {
|
match forge_grep::search_path(&opts) {
|
||||||
Ok(result) => Ok(NapiGrepResult {
|
Ok(result) => Ok(NapiGrepResult {
|
||||||
matches: result.matches.into_iter().map(convert_file_match).collect(),
|
matches: result.matches.into_iter().map(convert_file_match).collect(),
|
||||||
total_matches: clamp_u32(result.total_matches),
|
total_matches: clamp_u32(result.total_matches),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[package]
|
[package]
|
||||||
name = "gsd-grep"
|
name = "forge-grep"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"main": "gsd_engine.node",
|
"main": "forge_engine.node",
|
||||||
"files": [
|
"files": [
|
||||||
"gsd_engine.node"
|
"forge_engine.node"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"main": "gsd_engine.node",
|
"main": "forge_engine.node",
|
||||||
"files": [
|
"files": [
|
||||||
"gsd_engine.node"
|
"forge_engine.node"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"main": "gsd_engine.node",
|
"main": "forge_engine.node",
|
||||||
"files": [
|
"files": [
|
||||||
"gsd_engine.node"
|
"forge_engine.node"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"main": "gsd_engine.node",
|
"main": "forge_engine.node",
|
||||||
"files": [
|
"files": [
|
||||||
"gsd_engine.node"
|
"forge_engine.node"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"main": "gsd_engine.node",
|
"main": "forge_engine.node",
|
||||||
"files": [
|
"files": [
|
||||||
"gsd_engine.node"
|
"forge_engine.node"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const profile = isDev ? "debug" : "release";
|
||||||
const cargoArgs = ["build"];
|
const cargoArgs = ["build"];
|
||||||
if (!isDev) cargoArgs.push("--release");
|
if (!isDev) cargoArgs.push("--release");
|
||||||
|
|
||||||
console.log(`Building gsd-engine (${profile})...`);
|
console.log(`Building forge-engine (${profile})...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`cargo ${cargoArgs.join(" ")}`, {
|
execSync(`cargo ${cargoArgs.join(" ")}`, {
|
||||||
|
|
@ -52,9 +52,9 @@ const targetDir = path.join(cargoTargetRoot, profile);
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
|
|
||||||
const libraryNames = {
|
const libraryNames = {
|
||||||
darwin: "libgsd_engine.dylib",
|
darwin: "libforge_engine.dylib",
|
||||||
linux: "libgsd_engine.so",
|
linux: "libforge_engine.so",
|
||||||
win32: "gsd_engine.dll",
|
win32: "forge_engine.dll",
|
||||||
};
|
};
|
||||||
|
|
||||||
const libName = libraryNames[process.platform];
|
const libName = libraryNames[process.platform];
|
||||||
|
|
@ -72,8 +72,8 @@ if (!fs.existsSync(sourcePath)) {
|
||||||
fs.mkdirSync(addonDir, { recursive: true });
|
fs.mkdirSync(addonDir, { recursive: true });
|
||||||
|
|
||||||
const destFilename = isDev
|
const destFilename = isDev
|
||||||
? "gsd_engine.dev.node"
|
? "forge_engine.dev.node"
|
||||||
: `gsd_engine.${platformTag}.node`;
|
: `forge_engine.${platformTag}.node`;
|
||||||
const destPath = path.join(addonDir, destFilename);
|
const destPath = path.join(addonDir, destFilename);
|
||||||
|
|
||||||
fs.copyFileSync(sourcePath, destPath);
|
fs.copyFileSync(sourcePath, destPath);
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ const require = createRequire(import.meta.url);
|
||||||
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(addonDir, `gsd_engine.${platformTag}.node`),
|
path.join(addonDir, `forge_engine.${platformTag}.node`),
|
||||||
path.join(addonDir, "gsd_engine.dev.node"),
|
path.join(addonDir, "forge_engine.dev.node"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let native;
|
let native;
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ const addonDir = path.resolve(
|
||||||
);
|
);
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(addonDir, `gsd_engine.${platformTag}.node`),
|
path.join(addonDir, `forge_engine.${platformTag}.node`),
|
||||||
path.join(addonDir, "gsd_engine.dev.node"),
|
path.join(addonDir, "forge_engine.dev.node"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let native;
|
let native;
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ const require = createRequire(import.meta.url);
|
||||||
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(addonDir, `gsd_engine.${platformTag}.node`),
|
path.join(addonDir, `forge_engine.${platformTag}.node`),
|
||||||
path.join(addonDir, "gsd_engine.dev.node"),
|
path.join(addonDir, "forge_engine.dev.node"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let native;
|
let native;
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ const addonDir = path.resolve(
|
||||||
);
|
);
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(addonDir, `gsd_engine.${platformTag}.node`),
|
path.join(addonDir, `forge_engine.${platformTag}.node`),
|
||||||
path.join(addonDir, "gsd_engine.dev.node"),
|
path.join(addonDir, "forge_engine.dev.node"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let native;
|
let native;
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ const require = createRequire(import.meta.url);
|
||||||
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(addonDir, `gsd_engine.${platformTag}.node`),
|
path.join(addonDir, `forge_engine.${platformTag}.node`),
|
||||||
path.join(addonDir, "gsd_engine.dev.node"),
|
path.join(addonDir, "forge_engine.dev.node"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let native;
|
let native;
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ const require = createRequire(import.meta.url);
|
||||||
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(addonDir, `gsd_engine.${platformTag}.node`),
|
path.join(addonDir, `forge_engine.${platformTag}.node`),
|
||||||
path.join(addonDir, "gsd_engine.dev.node"),
|
path.join(addonDir, "forge_engine.dev.node"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let native;
|
let native;
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ const require = createRequire(import.meta.url);
|
||||||
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(addonDir, `gsd_engine.${platformTag}.node`),
|
path.join(addonDir, `forge_engine.${platformTag}.node`),
|
||||||
path.join(addonDir, "gsd_engine.dev.node"),
|
path.join(addonDir, "forge_engine.dev.node"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let native;
|
let native;
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ const require = createRequire(import.meta.url);
|
||||||
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(addonDir, `gsd_engine.${platformTag}.node`),
|
path.join(addonDir, `forge_engine.${platformTag}.node`),
|
||||||
path.join(addonDir, "gsd_engine.dev.node"),
|
path.join(addonDir, "forge_engine.dev.node"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let native;
|
let native;
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ const require = createRequire(import.meta.url);
|
||||||
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(addonDir, `gsd_engine.${platformTag}.node`),
|
path.join(addonDir, `forge_engine.${platformTag}.node`),
|
||||||
path.join(addonDir, "gsd_engine.dev.node"),
|
path.join(addonDir, "forge_engine.dev.node"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let native;
|
let native;
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ const require = createRequire(import.meta.url);
|
||||||
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(addonDir, `gsd_engine.${platformTag}.node`),
|
path.join(addonDir, `forge_engine.${platformTag}.node`),
|
||||||
path.join(addonDir, "gsd_engine.dev.node"),
|
path.join(addonDir, "forge_engine.dev.node"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let native;
|
let native;
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ const addonDir = path.resolve(
|
||||||
);
|
);
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(addonDir, `gsd_engine.${platformTag}.node`),
|
path.join(addonDir, `forge_engine.${platformTag}.node`),
|
||||||
path.join(addonDir, "gsd_engine.dev.node"),
|
path.join(addonDir, "forge_engine.dev.node"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let native;
|
let native;
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ const require = createRequire(import.meta.url);
|
||||||
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(addonDir, `gsd_engine.${platformTag}.node`),
|
path.join(addonDir, `forge_engine.${platformTag}.node`),
|
||||||
path.join(addonDir, "gsd_engine.dev.node"),
|
path.join(addonDir, "forge_engine.dev.node"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let native;
|
let native;
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ const require = createRequire(import.meta.url);
|
||||||
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
|
||||||
const platformTag = `${process.platform}-${process.arch}`;
|
const platformTag = `${process.platform}-${process.arch}`;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(addonDir, `gsd_engine.${platformTag}.node`),
|
path.join(addonDir, `forge_engine.${platformTag}.node`),
|
||||||
path.join(addonDir, "gsd_engine.dev.node"),
|
path.join(addonDir, "forge_engine.dev.node"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let native;
|
let native;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
* Locates and loads the compiled Rust N-API addon (`.node` file).
|
* Locates and loads the compiled Rust N-API addon (`.node` file).
|
||||||
* Resolution order:
|
* Resolution order:
|
||||||
* 1. @singularity-forge/engine-{platform} npm optional dependency (production install)
|
* 1. @singularity-forge/engine-{platform} npm optional dependency (production install)
|
||||||
* 2. native/addon/gsd_engine.{platform}.node (local release build)
|
* 2. native/addon/forge_engine.{platform}.node (local release build)
|
||||||
* 3. native/addon/gsd_engine.dev.node (local debug build)
|
* 3. native/addon/forge_engine.dev.node (local debug build)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
|
@ -44,8 +44,8 @@ function loadNative(): Record<string, unknown> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Try local release build (native/addon/gsd_engine.{platform}.node)
|
// 2. Try local release build (native/addon/forge_engine.{platform}.node)
|
||||||
const releasePath = path.join(addonDir, `gsd_engine.${platformTag}.node`);
|
const releasePath = path.join(addonDir, `forge_engine.${platformTag}.node`);
|
||||||
try {
|
try {
|
||||||
_loadedSuccessfully = true; return _require(releasePath) as Record<string, unknown>;
|
_loadedSuccessfully = true; return _require(releasePath) as Record<string, unknown>;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -53,8 +53,8 @@ function loadNative(): Record<string, unknown> {
|
||||||
errors.push(`${releasePath}: ${message}`);
|
errors.push(`${releasePath}: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Try local dev build (native/addon/gsd_engine.dev.node)
|
// 3. Try local dev build (native/addon/forge_engine.dev.node)
|
||||||
const devPath = path.join(addonDir, "gsd_engine.dev.node");
|
const devPath = path.join(addonDir, "forge_engine.dev.node");
|
||||||
try {
|
try {
|
||||||
_loadedSuccessfully = true; return _require(devPath) as Record<string, unknown>;
|
_loadedSuccessfully = true; return _require(devPath) as Record<string, unknown>;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -70,7 +70,7 @@ function loadNative(): Record<string, unknown> {
|
||||||
// entire import chain at startup (#1223). Consumers with JS fallbacks
|
// entire import chain at startup (#1223). Consumers with JS fallbacks
|
||||||
// (parseRoadmap, parsePlan, fuzzyFind, etc.) catch these and degrade gracefully.
|
// (parseRoadmap, parsePlan, fuzzyFind, etc.) catch these and degrade gracefully.
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`[gsd] Native addon not available for ${platformTag}. Falling back to JS implementations (slower).\n` +
|
`[forge] Native addon not available for ${platformTag}. Falling back to JS implementations (slower).\n` +
|
||||||
` Supported native platforms: ${supportedPlatforms.join(", ")}\n`,
|
` Supported native platforms: ${supportedPlatforms.join(", ")}\n`,
|
||||||
);
|
);
|
||||||
return new Proxy({} as Record<string, unknown>, {
|
return new Proxy({} as Record<string, unknown>, {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const { join, resolve } = require('node:path')
|
||||||
|
|
||||||
// Skip on Windows — Next.js webpack build hits EPERM scanning system dirs
|
// Skip on Windows — Next.js webpack build hits EPERM scanning system dirs
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
console.log('[gsd] Web build skipped on Windows.')
|
console.log('[forge] Web build skipped on Windows.')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@ function ensureWebBuildDependencies() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[gsd] Web build dependencies are missing or incomplete — running npm --prefix web ci...')
|
console.log('[forge] Web build dependencies are missing or incomplete — running npm --prefix web ci...')
|
||||||
execSync('npm --prefix web ci', { cwd: root, stdio: 'inherit' })
|
execSync('npm --prefix web ci', { cwd: root, stdio: 'inherit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,20 +91,20 @@ const sourceMtime = Math.max(newestMtime(webRoot), newestMtime(srcRoot))
|
||||||
const builtMtime = sentinelMtime()
|
const builtMtime = sentinelMtime()
|
||||||
|
|
||||||
if (builtMtime > 0 && builtMtime >= sourceMtime) {
|
if (builtMtime > 0 && builtMtime >= sourceMtime) {
|
||||||
console.log('[gsd] Web build is up-to-date, skipping rebuild.')
|
console.log('[forge] Web build is up-to-date, skipping rebuild.')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (builtMtime === 0) {
|
if (builtMtime === 0) {
|
||||||
console.log('[gsd] No staged web build found — building now...')
|
console.log('[forge] No staged web build found — building now...')
|
||||||
} else {
|
} else {
|
||||||
console.log('[gsd] Web/src source has changed since last build — rebuilding...')
|
console.log('[forge] Web/src source has changed since last build — rebuilding...')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureWebBuildDependencies()
|
ensureWebBuildDependencies()
|
||||||
execSync('npm run build:web-host', { cwd: root, stdio: 'inherit' })
|
execSync('npm run build:web-host', { cwd: root, stdio: 'inherit' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[gsd] Web build failed:', err.message)
|
console.error('[forge] Web build failed:', err.message)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ const child = spawn(
|
||||||
)
|
)
|
||||||
|
|
||||||
child.on('error', (error) => {
|
child.on('error', (error) => {
|
||||||
console.error(`[gsd] Failed to launch local dev CLI: ${error instanceof Error ? error.message : String(error)}`)
|
console.error(`[forge] Failed to launch local dev CLI: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ function run(cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function logWarn(message) {
|
function logWarn(message) {
|
||||||
process.stderr.write(`[gsd] postinstall: ${message}\n`)
|
process.stderr.write(`[forge] postinstall: ${message}\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAssetName() {
|
function resolveAssetName() {
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ function overlayNodePty(targetRoot) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existsSync(standaloneAppRoot)) {
|
if (!existsSync(standaloneAppRoot)) {
|
||||||
console.error('[gsd] Web standalone build not found at web/.next/standalone/web. Run `npm --prefix web run build` first.')
|
console.error('[forge] Web standalone build not found at web/.next/standalone/web. Run `npm --prefix web run build` first.')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,7 +67,7 @@ if (existsSync(publicRoot)) {
|
||||||
|
|
||||||
const hydratedTargets = overlayNodePty(distStandaloneRoot)
|
const hydratedTargets = overlayNodePty(distStandaloneRoot)
|
||||||
|
|
||||||
console.log(`[gsd] Staged web standalone host at ${distStandaloneRoot}`)
|
console.log(`[forge] Staged web standalone host at ${distStandaloneRoot}`)
|
||||||
if (hydratedTargets.length > 0) {
|
if (hydratedTargets.length > 0) {
|
||||||
console.log(`[gsd] Hydrated node-pty native assets in ${hydratedTargets.length} location(s).`)
|
console.log(`[forge] Hydrated node-pty native assets in ${hydratedTargets.length} location(s).`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ export function migrateLegacyFlatSessions(baseSessionsDir: string, projectSessio
|
||||||
|
|
||||||
function emitWebModeFailure(stderr: WritableLike, status: WebModeLaunchStatus): void {
|
function emitWebModeFailure(stderr: WritableLike, status: WebModeLaunchStatus): void {
|
||||||
if (status.ok) return
|
if (status.ok) return
|
||||||
stderr.write(`[gsd] Web mode launch failed: ${status.failureReason}\n`)
|
stderr.write(`[forge] Web mode launch failed: ${status.failureReason}\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -249,7 +249,7 @@ export async function runWebCliBranch(
|
||||||
currentCwd = resolve(defaultCwd, webPath)
|
currentCwd = resolve(defaultCwd, webPath)
|
||||||
const checkExists = existsSync
|
const checkExists = existsSync
|
||||||
if (!checkExists(currentCwd)) {
|
if (!checkExists(currentCwd)) {
|
||||||
stderr.write(`[gsd] Project path does not exist: ${currentCwd}\n`)
|
stderr.write(`[forge] Project path does not exist: ${currentCwd}\n`)
|
||||||
return {
|
return {
|
||||||
handled: true,
|
handled: true,
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
|
|
@ -270,7 +270,7 @@ export async function runWebCliBranch(
|
||||||
launchInputs: { cwd: currentCwd, projectSessionsDir: '', agentDir: deps.agentDir ?? defaultAgentDir },
|
launchInputs: { cwd: currentCwd, projectSessionsDir: '', agentDir: deps.agentDir ?? defaultAgentDir },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stderr.write(`[gsd] Using project path: ${currentCwd}\n`)
|
stderr.write(`[forge] Using project path: ${currentCwd}\n`)
|
||||||
} else {
|
} else {
|
||||||
currentCwd = defaultCwd
|
currentCwd = defaultCwd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -681,7 +681,7 @@ if (!cliFlags.worktree && !isPrintMode) {
|
||||||
// which handles non-interactive output gracefully.
|
// which handles non-interactive output gracefully.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
if (cliFlags.messages[0] === 'auto' && !process.stdout.isTTY) {
|
if (cliFlags.messages[0] === 'auto' && !process.stdout.isTTY) {
|
||||||
process.stderr.write('[gsd] stdout is not a terminal — running auto-mode in headless mode.\n')
|
process.stderr.write('[forge] stdout is not a terminal — running auto-mode in headless mode.\n')
|
||||||
await runHeadlessFromAuto(cliFlags.messages.slice(1))
|
await runHeadlessFromAuto(cliFlags.messages.slice(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -303,8 +303,8 @@ export function formatProgress(event: Record<string, unknown>, ctx: ProgressCont
|
||||||
// Bold important notifications
|
// Bold important notifications
|
||||||
const isImportant = /^(committed:|verification gate:|milestone|blocked:)/i.test(msg)
|
const isImportant = /^(committed:|verification gate:|milestone|blocked:)/i.test(msg)
|
||||||
return isImportant
|
return isImportant
|
||||||
? `${c.bold}[gsd] ${msg}${c.reset}`
|
? `${c.bold}[forge] ${msg}${c.reset}`
|
||||||
: `[gsd] ${msg}`
|
: `[forge] ${msg}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'setStatus') {
|
if (method === 'setStatus') {
|
||||||
|
|
|
||||||
|
|
@ -134,5 +134,5 @@ export async function startMcpServer(options: {
|
||||||
// Connect to stdin/stdout transport
|
// Connect to stdin/stdout transport
|
||||||
const transport = new StdioServerTransport()
|
const transport = new StdioServerTransport()
|
||||||
await server.connect(transport)
|
await server.connect(transport)
|
||||||
process.stderr.write(`[gsd] MCP server started (v${version})\n`)
|
process.stderr.write(`[forge] MCP server started (v${version})\n`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ async function loadClack(): Promise<ClackModule> {
|
||||||
try {
|
try {
|
||||||
return await import('@clack/prompts')
|
return await import('@clack/prompts')
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('[gsd] @clack/prompts not found — onboarding wizard requires this dependency')
|
throw new Error('[forge] @clack/prompts not found — onboarding wizard requires this dependency')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,7 +220,7 @@ export async function runOnboarding(authStorage: AuthStorage): Promise<void> {
|
||||||
;[p, pc] = await Promise.all([loadClack(), loadPico()])
|
;[p, pc] = await Promise.all([loadClack(), loadPico()])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If clack isn't available, fall back silently — don't block boot
|
// If clack isn't available, fall back silently — don't block boot
|
||||||
process.stderr.write(`[gsd] Onboarding wizard unavailable: ${err instanceof Error ? err.message : String(err)}\n`)
|
process.stderr.write(`[forge] Onboarding wizard unavailable: ${err instanceof Error ? err.message : String(err)}\n`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export function migratePiCredentials(authStorage: AuthStorage): boolean {
|
||||||
authStorage.set(providerId, credential)
|
authStorage.set(providerId, credential)
|
||||||
const isLlm = LLM_PROVIDER_IDS.includes(providerId)
|
const isLlm = LLM_PROVIDER_IDS.includes(providerId)
|
||||||
if (isLlm) migratedLlm = true
|
if (isLlm) migratedLlm = true
|
||||||
process.stderr.write(`[gsd] Migrated ${isLlm ? 'LLM provider' : 'credential'}: ${providerId} (from Pi)\n`)
|
process.stderr.write(`[forge] Migrated ${isLlm ? 'LLM provider' : 'credential'}: ${providerId} (from Pi)\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return migratedLlm
|
return migratedLlm
|
||||||
|
|
|
||||||
|
|
@ -352,7 +352,7 @@ function reconcileSymlink(link: string, target: string): void {
|
||||||
try {
|
try {
|
||||||
symlinkSync(target, link, 'junction')
|
symlinkSync(target, link, 'junction')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[gsd] WARN: Failed to symlink ${link} → ${target}: ${err instanceof Error ? err.message : err}`)
|
console.error(`[forge] WARN: Failed to symlink ${link} → ${target}: ${err instanceof Error ? err.message : err}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -397,7 +397,7 @@ function reconcileMergedNodeModules(
|
||||||
try { symlinkSync(join(hoisted, entry.name), join(agentNodeModules, entry.name), 'junction'); linkedCount++ } catch { /* skip individual */ }
|
try { symlinkSync(join(hoisted, entry.name), join(agentNodeModules, entry.name), 'junction'); linkedCount++ } catch { /* skip individual */ }
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[gsd] WARN: Failed to read hoisted node_modules at ${hoisted}: ${err instanceof Error ? err.message : err}`)
|
console.error(`[forge] WARN: Failed to read hoisted node_modules at ${hoisted}: ${err instanceof Error ? err.message : err}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overlay internal node_modules entries that weren't hoisted.
|
// Overlay internal node_modules entries that weren't hoisted.
|
||||||
|
|
@ -412,7 +412,7 @@ function reconcileMergedNodeModules(
|
||||||
try { symlinkSync(join(internal, entry.name), link, 'junction'); linkedCount++ } catch { /* skip individual */ }
|
try { symlinkSync(join(internal, entry.name), link, 'junction'); linkedCount++ } catch { /* skip individual */ }
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[gsd] WARN: Failed to read internal node_modules at ${internal}: ${err instanceof Error ? err.message : err}`)
|
console.error(`[forge] WARN: Failed to read internal node_modules at ${internal}: ${err instanceof Error ? err.message : err}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only stamp marker if we actually linked something — avoids caching a broken state
|
// Only stamp marker if we actually linked something — avoids caching a broken state
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ const SEQ_PREFIX_RE = /^(\d+)-/;
|
||||||
import type { ExtensionContext } from "@sf-run/pi-coding-agent";
|
import type { ExtensionContext } from "@sf-run/pi-coding-agent";
|
||||||
import { gsdRoot } from "./paths.js";
|
import { gsdRoot } from "./paths.js";
|
||||||
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
|
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
|
||||||
import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js";
|
import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js";
|
||||||
|
|
||||||
interface ActivityLogState {
|
interface ActivityLogState {
|
||||||
nextSeq: number;
|
nextSeq: number;
|
||||||
|
|
@ -135,7 +135,7 @@ export function saveActivityLog(
|
||||||
state.nextSeq += 1;
|
state.nextSeq += 1;
|
||||||
state.lastSnapshotKeyByUnit.set(unitKey, key);
|
state.lastSnapshotKeyByUnit.set(unitKey, key);
|
||||||
|
|
||||||
if (isUnifiedAuditEnabled()) {
|
if (isAuditEnvelopeEnabled()) {
|
||||||
emitUokAuditEvent(
|
emitUokAuditEvent(
|
||||||
basePath,
|
basePath,
|
||||||
buildAuditEnvelope({
|
buildAuditEnvelope({
|
||||||
|
|
|
||||||
|
|
@ -489,8 +489,8 @@ export async function bootstrapAutoSession(
|
||||||
// Route to the interactive discussion handler instead of falling through to
|
// Route to the interactive discussion handler instead of falling through to
|
||||||
// auto-mode, which would immediately stop with "needs discussion".
|
// auto-mode, which would immediately stop with "needs discussion".
|
||||||
if (hasSurvivorBranch && state.phase === "needs-discussion") {
|
if (hasSurvivorBranch && state.phase === "needs-discussion") {
|
||||||
const { showSmartEntry } = await import("./guided-flow.js");
|
const { showWorkflowEntry } = await import("./guided-flow.js");
|
||||||
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
|
await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });
|
||||||
|
|
||||||
invalidateAllCaches();
|
invalidateAllCaches();
|
||||||
const postState = await deriveState(base);
|
const postState = await deriveState(base);
|
||||||
|
|
@ -547,8 +547,8 @@ export async function bootstrapAutoSession(
|
||||||
return releaseLockAndReturn();
|
return releaseLockAndReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { showSmartEntry } = await import("./guided-flow.js");
|
const { showWorkflowEntry } = await import("./guided-flow.js");
|
||||||
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
|
await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });
|
||||||
|
|
||||||
invalidateAllCaches();
|
invalidateAllCaches();
|
||||||
const postState = await deriveState(base);
|
const postState = await deriveState(base);
|
||||||
|
|
@ -589,8 +589,8 @@ export async function bootstrapAutoSession(
|
||||||
const contextFile = resolveMilestoneFile(base, mid, "CONTEXT");
|
const contextFile = resolveMilestoneFile(base, mid, "CONTEXT");
|
||||||
const hasContext = !!(contextFile && (await loadFile(contextFile)));
|
const hasContext = !!(contextFile && (await loadFile(contextFile)));
|
||||||
if (!hasContext) {
|
if (!hasContext) {
|
||||||
const { showSmartEntry } = await import("./guided-flow.js");
|
const { showWorkflowEntry } = await import("./guided-flow.js");
|
||||||
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
|
await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });
|
||||||
|
|
||||||
invalidateAllCaches();
|
invalidateAllCaches();
|
||||||
const postState = await deriveState(base);
|
const postState = await deriveState(base);
|
||||||
|
|
@ -608,8 +608,8 @@ export async function bootstrapAutoSession(
|
||||||
|
|
||||||
// Active milestone has CONTEXT-DRAFT but no full context — needs discussion
|
// Active milestone has CONTEXT-DRAFT but no full context — needs discussion
|
||||||
if (state.phase === "needs-discussion") {
|
if (state.phase === "needs-discussion") {
|
||||||
const { showSmartEntry } = await import("./guided-flow.js");
|
const { showWorkflowEntry } = await import("./guided-flow.js");
|
||||||
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
|
await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });
|
||||||
|
|
||||||
invalidateAllCaches();
|
invalidateAllCaches();
|
||||||
const postState = await deriveState(base);
|
const postState = await deriveState(base);
|
||||||
|
|
@ -630,8 +630,8 @@ export async function bootstrapAutoSession(
|
||||||
|
|
||||||
// Unreachable safety check
|
// Unreachable safety check
|
||||||
if (!state.activeMilestone) {
|
if (!state.activeMilestone) {
|
||||||
const { showSmartEntry } = await import("./guided-flow.js");
|
const { showWorkflowEntry } = await import("./guided-flow.js");
|
||||||
await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
|
await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });
|
||||||
return releaseLockAndReturn();
|
return releaseLockAndReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ import { withTimeout, FINALIZE_PRE_TIMEOUT_MS, FINALIZE_POST_TIMEOUT_MS } from "
|
||||||
import { getEligibleSlices } from "../slice-parallel-eligibility.js";
|
import { getEligibleSlices } from "../slice-parallel-eligibility.js";
|
||||||
import { startSliceParallel } from "../slice-parallel-orchestrator.js";
|
import { startSliceParallel } from "../slice-parallel-orchestrator.js";
|
||||||
import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js";
|
import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js";
|
||||||
import { ensurePlanV2Graph } from "../uok/plan-v2.js";
|
import { ensurePlanV2Graph as ensurePlanningFlowGraph } from "../uok/plan-v2.js";
|
||||||
import { resolveUokFlags } from "../uok/flags.js";
|
import { resolveUokFlags } from "../uok/flags.js";
|
||||||
import { UokGateRunner } from "../uok/gate-runner.js";
|
import { UokGateRunner } from "../uok/gate-runner.js";
|
||||||
import { resetEvidence } from "../safety/evidence-collector.js";
|
import { resetEvidence } from "../safety/evidence-collector.js";
|
||||||
|
|
@ -84,15 +84,15 @@ export function _resolveDispatchGuardBasePath(
|
||||||
return s.originalBasePath || s.basePath;
|
return s.originalBasePath || s.basePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLAN_V2_GATE_PHASES: ReadonlySet<Phase> = new Set([
|
const PLANNING_FLOW_GATE_PHASES: ReadonlySet<Phase> = new Set([
|
||||||
"executing",
|
"executing",
|
||||||
"summarizing",
|
"summarizing",
|
||||||
"validating-milestone",
|
"validating-milestone",
|
||||||
"completing-milestone",
|
"completing-milestone",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function shouldRunPlanV2Gate(phase: Phase): boolean {
|
function shouldRunPlanningFlowGate(phase: Phase): boolean {
|
||||||
return PLAN_V2_GATE_PHASES.has(phase);
|
return PLANNING_FLOW_GATE_PHASES.has(phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldSkipArtifactVerification(unitType: string): boolean {
|
function shouldSkipArtifactVerification(unitType: string): boolean {
|
||||||
|
|
@ -404,29 +404,30 @@ export async function runPreDispatch(
|
||||||
|
|
||||||
// Derive state
|
// Derive state
|
||||||
let state = await deps.deriveState(s.basePath);
|
let state = await deps.deriveState(s.basePath);
|
||||||
if (prefs?.uok?.plan_v2?.enabled && shouldRunPlanV2Gate(state.phase)) {
|
const planningFlowEnabled = prefs?.uok?.planning_flow?.enabled === true || prefs?.uok?.plan_v2?.enabled === true;
|
||||||
const compiled = ensurePlanV2Graph(s.basePath, state);
|
if (planningFlowEnabled && shouldRunPlanningFlowGate(state.phase)) {
|
||||||
|
const compiled = ensurePlanningFlowGraph(s.basePath, state);
|
||||||
if (!compiled.ok) {
|
if (!compiled.ok) {
|
||||||
const reason = compiled.reason ?? "Plan v2 compilation failed";
|
const reason = compiled.reason ?? "Planning flow compilation failed";
|
||||||
await runPreDispatchGate({
|
await runPreDispatchGate({
|
||||||
gateId: "plan-v2-gate",
|
gateId: "planning-flow-gate",
|
||||||
gateType: "policy",
|
gateType: "policy",
|
||||||
outcome: "manual-attention",
|
outcome: "manual-attention",
|
||||||
failureClass: "manual-attention",
|
failureClass: "manual-attention",
|
||||||
rationale: "plan v2 compile gate failed",
|
rationale: "planning flow compile gate failed",
|
||||||
findings: reason,
|
findings: reason,
|
||||||
milestoneId: state.activeMilestone?.id ?? undefined,
|
milestoneId: state.activeMilestone?.id ?? undefined,
|
||||||
});
|
});
|
||||||
ctx.ui.notify(`Plan gate failed-closed: ${reason}`, "error");
|
ctx.ui.notify(`Plan gate failed-closed: ${reason}`, "error");
|
||||||
await deps.pauseAuto(ctx, pi);
|
await deps.pauseAuto(ctx, pi);
|
||||||
return { action: "break", reason: "plan-v2-gate-failed" };
|
return { action: "break", reason: "planning-flow-gate-failed" };
|
||||||
}
|
}
|
||||||
await runPreDispatchGate({
|
await runPreDispatchGate({
|
||||||
gateId: "plan-v2-gate",
|
gateId: "planning-flow-gate",
|
||||||
gateType: "policy",
|
gateType: "policy",
|
||||||
outcome: "pass",
|
outcome: "pass",
|
||||||
failureClass: "none",
|
failureClass: "none",
|
||||||
rationale: "plan v2 compile gate passed",
|
rationale: "planning flow compile gate passed",
|
||||||
milestoneId: state.activeMilestone?.id ?? undefined,
|
milestoneId: state.activeMilestone?.id ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export function writeCrashLog(err: Error, source: string): void {
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const logPath = join(crashDir, `${ts}.log`);
|
const logPath = join(crashDir, `${ts}.log`);
|
||||||
const lines = [
|
const lines = [
|
||||||
`[gsd] ${source}: ${err.message}`,
|
`[forge] ${source}: ${err.message}`,
|
||||||
`timestamp: ${new Date().toISOString()}`,
|
`timestamp: ${new Date().toISOString()}`,
|
||||||
`pid: ${process.pid}`,
|
`pid: ${process.pid}`,
|
||||||
err.stack ?? "(no stack trace available)",
|
err.stack ?? "(no stack trace available)",
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,11 @@ export function handleRecoverableExtensionProcessError(err: Error): boolean {
|
||||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
const syscall = (err as NodeJS.ErrnoException).syscall;
|
const syscall = (err as NodeJS.ErrnoException).syscall;
|
||||||
if (syscall?.startsWith("spawn")) {
|
if (syscall?.startsWith("spawn")) {
|
||||||
process.stderr.write(`[gsd] spawn ENOENT: ${(err as any).path ?? "unknown"} — command not found\n`);
|
process.stderr.write(`[forge] spawn ENOENT: ${(err as any).path ?? "unknown"} — command not found\n`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (syscall === "uv_cwd") {
|
if (syscall === "uv_cwd") {
|
||||||
process.stderr.write(`[gsd] ENOENT (${syscall}): ${err.message}\n`);
|
process.stderr.write(`[forge] ENOENT (${syscall}): ${err.message}\n`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export function registerHooks(pi: ExtensionAPI): void {
|
||||||
const sid = ctx.sessionManager?.getSessionId?.() ?? "";
|
const sid = ctx.sessionManager?.getSessionId?.() ?? "";
|
||||||
const sfile = ctx.sessionManager?.getSessionFile?.() ?? "";
|
const sfile = ctx.sessionManager?.getSessionFile?.() ?? "";
|
||||||
if (sid) {
|
if (sid) {
|
||||||
process.stderr.write(`[gsd] session ${sid.slice(0, 8)} · ${sfile}\n`);
|
process.stderr.write(`[forge] session ${sid.slice(0, 8)} · ${sfile}\n`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* non-fatal */
|
/* non-fatal */
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// GSD Extension — Unified Cache Invalidation
|
// GSD Extension — Cache Invalidation
|
||||||
//
|
//
|
||||||
// Three module-scoped caches exist across the GSD extension:
|
// Three module-scoped caches exist across the GSD extension:
|
||||||
// 1. State cache (state.ts) — memoized deriveState() result
|
// 1. State cache (state.ts) — memoized deriveState() result
|
||||||
|
|
|
||||||
|
|
@ -267,8 +267,8 @@ export async function handleWorkflowCommand(trimmed: string, ctx: ExtensionComma
|
||||||
try { unlinkSync(headlessContextPath); } catch { /* non-fatal */ }
|
try { unlinkSync(headlessContextPath); } catch { /* non-fatal */ }
|
||||||
await showHeadlessMilestoneCreation(ctx, pi, basePath, seedContext);
|
await showHeadlessMilestoneCreation(ctx, pi, basePath, seedContext);
|
||||||
} else {
|
} else {
|
||||||
const { showSmartEntry } = await import("../../guided-flow.js");
|
const { showWorkflowEntry } = await import("../../guided-flow.js");
|
||||||
await showSmartEntry(ctx, pi, basePath);
|
await showWorkflowEntry(ctx, pi, basePath);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
|
||||||
- `hooks`: boolean — enable routing hooks. Default: `true`.
|
- `hooks`: boolean — enable routing hooks. Default: `true`.
|
||||||
- `capability_routing`: boolean — enable capability-profile scoring for model selection within a tier. Requires `enabled: true`. Default: `false`.
|
- `capability_routing`: boolean — enable capability-profile scoring for model selection within a tier. Requires `enabled: true`. Default: `false`.
|
||||||
|
|
||||||
- `uok`: Unified Orchestration Kernel controls. Keys:
|
- `uok`: orchestration kernel controls. Keys:
|
||||||
- `enabled`: boolean — enable kernel wrappers and contract observers. Default: `true`.
|
- `enabled`: boolean — enable kernel wrappers and contract observers. Default: `true`.
|
||||||
- `legacy_fallback.enabled`: boolean — emergency release fallback that forces legacy orchestration behavior even when `uok.enabled` is `true`. Default: `false`.
|
- `legacy_fallback.enabled`: boolean — emergency release fallback that forces legacy orchestration behavior even when `uok.enabled` is `true`. Default: `false`.
|
||||||
- Runtime override: set `GSD_UOK_FORCE_LEGACY=1` (or `GSD_UOK_LEGACY_FALLBACK=1`) to force legacy behavior for the current process.
|
- Runtime override: set `GSD_UOK_FORCE_LEGACY=1` (or `GSD_UOK_LEGACY_FALLBACK=1`) to force legacy behavior for the current process.
|
||||||
|
|
@ -203,8 +203,8 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
|
||||||
- `gitops.enabled`: boolean — persist turn-level git transaction records.
|
- `gitops.enabled`: boolean — persist turn-level git transaction records.
|
||||||
- `gitops.turn_action`: `"commit"` | `"snapshot"` | `"status-only"` — turn transaction mode.
|
- `gitops.turn_action`: `"commit"` | `"snapshot"` | `"status-only"` — turn transaction mode.
|
||||||
- `gitops.turn_push`: boolean — whether turn transactions should include push intent metadata.
|
- `gitops.turn_push`: boolean — whether turn transactions should include push intent metadata.
|
||||||
- `audit_unified.enabled`: boolean — dual-write unified audit envelope events.
|
- `audit_envelope.enabled`: boolean — dual-write audit envelope events.
|
||||||
- `plan_v2.enabled`: boolean — enable bounded clarify/research/draft/compile planning flow.
|
- `planning_flow.enabled`: boolean — enable bounded clarify/research/draft/compile planning flow.
|
||||||
|
|
||||||
- `context_management`: configures context hygiene for auto-mode sessions. Keys:
|
- `context_management`: configures context hygiene for auto-mode sessions. Keys:
|
||||||
- `observation_masking`: boolean — mask old tool results to reduce context bloat. Default: `true`.
|
- `observation_masking`: boolean — mask old tool results to reduce context bloat. Default: `true`.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Unified error classifier for provider/network/server errors.
|
* Error classifier for provider/network/server failures.
|
||||||
*
|
*
|
||||||
* Consolidates patterns from:
|
* Consolidates patterns from:
|
||||||
* - isTransientNetworkError() in preferences-models.ts
|
* - isTransientNetworkError() in preferences-models.ts
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* GSD Guided Flow — Smart Entry Wizard
|
* GSD Guided Flow — Workflow Entry Wizard
|
||||||
*
|
*
|
||||||
* One function: showSmartEntry(). Reads state from disk, shows a contextual
|
* Primary entrypoints: `showWorkflowEntry()` and the legacy `showSmartEntry()`
|
||||||
* wizard via showNextAction(), and dispatches through GSD-WORKFLOW.md.
|
* export. Reads state from disk, shows a contextual wizard via
|
||||||
|
* `showNextAction()`, and dispatches through GSD-WORKFLOW.md.
|
||||||
* No execution state, no hooks, no tools — the LLM does the rest.
|
* No execution state, no hooks, no tools — the LLM does the rest.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -38,7 +39,7 @@ import { isInheritedRepo } from "./repo-identity.js";
|
||||||
import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js";
|
import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js";
|
||||||
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
||||||
import { resolveUokFlags } from "./uok/flags.js";
|
import { resolveUokFlags } from "./uok/flags.js";
|
||||||
import { ensurePlanV2Graph } from "./uok/plan-v2.js";
|
import { ensurePlanV2Graph as ensurePlanningFlowGraph } from "./uok/plan-v2.js";
|
||||||
import { detectProjectState } from "./detection.js";
|
import { detectProjectState } from "./detection.js";
|
||||||
import { showProjectInit, offerMigration } from "./init-wizard.js";
|
import { showProjectInit, offerMigration } from "./init-wizard.js";
|
||||||
import { validateDirectory } from "./validate-directory.js";
|
import { validateDirectory } from "./validate-directory.js";
|
||||||
|
|
@ -86,24 +87,24 @@ function nextMilestoneIdReserved(existingIds: string[], uniqueEnabled: boolean):
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function needsPlanV2Gate(state: GSDState): boolean {
|
function needsPlanningFlowGate(state: GSDState): boolean {
|
||||||
return state.phase === "executing"
|
return state.phase === "executing"
|
||||||
|| state.phase === "summarizing"
|
|| state.phase === "summarizing"
|
||||||
|| state.phase === "validating-milestone"
|
|| state.phase === "validating-milestone"
|
||||||
|| state.phase === "completing-milestone";
|
|| state.phase === "completing-milestone";
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPlanV2Gate(
|
function runPlanningFlowGate(
|
||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
basePath: string,
|
basePath: string,
|
||||||
state: GSDState,
|
state: GSDState,
|
||||||
): boolean {
|
): boolean {
|
||||||
const prefs = loadEffectiveGSDPreferences()?.preferences;
|
const prefs = loadEffectiveGSDPreferences()?.preferences;
|
||||||
const uokFlags = resolveUokFlags(prefs);
|
const uokFlags = resolveUokFlags(prefs);
|
||||||
if (!uokFlags.planV2 || !needsPlanV2Gate(state)) return true;
|
if (!uokFlags.planningFlow || !needsPlanningFlowGate(state)) return true;
|
||||||
const compiled = ensurePlanV2Graph(basePath, state);
|
const compiled = ensurePlanningFlowGraph(basePath, state);
|
||||||
if (!compiled.ok) {
|
if (!compiled.ok) {
|
||||||
const reason = compiled.reason ?? "plan-v2 compilation failed";
|
const reason = compiled.reason ?? "planning-flow compilation failed";
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
`Plan gate failed-closed: ${reason}. Complete plan/discuss artifacts before execution.`,
|
`Plan gate failed-closed: ${reason}. Complete plan/discuss artifacts before execution.`,
|
||||||
"error",
|
"error",
|
||||||
|
|
@ -1079,16 +1080,16 @@ async function dispatchDiscussForMilestone(
|
||||||
await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "discuss-milestone");
|
await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "discuss-milestone");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Smart Entry Point ────────────────────────────────────────────────────────
|
// ─── Workflow Entry Point ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one wizard. Reads state, shows contextual options, dispatches into the workflow doc.
|
* The workflow entry wizard. Reads state, shows contextual options, and dispatches into the workflow doc.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Self-heal: scan runtime records and clear stale ones left behind when
|
* Self-heal: scan runtime records and clear stale ones left behind when
|
||||||
* auto-mode crashed mid-unit. auto.ts has its own selfHealRuntimeRecords()
|
* auto-mode crashed mid-unit. auto.ts has its own selfHealRuntimeRecords()
|
||||||
* but guided-flow (manual /gsd mode) never called it — meaning stale records
|
* but guided-flow (manual /gsd mode) never called it — meaning stale records
|
||||||
* persisted until the next /gsd auto run. This ensures the wizard always
|
* persisted until the next /gsd auto run. This ensures the workflow entry
|
||||||
* starts from a clean state regardless of how the previous session ended.
|
* starts from a clean state regardless of how the previous session ended.
|
||||||
*/
|
*/
|
||||||
function selfHealRuntimeRecords(basePath: string, ctx: ExtensionContext): { cleared: number } {
|
function selfHealRuntimeRecords(basePath: string, ctx: ExtensionContext): { cleared: number } {
|
||||||
|
|
@ -1224,7 +1225,7 @@ async function handleMilestoneActions(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function showSmartEntry(
|
export async function showWorkflowEntry(
|
||||||
ctx: ExtensionCommandContext,
|
ctx: ExtensionCommandContext,
|
||||||
pi: ExtensionAPI,
|
pi: ExtensionAPI,
|
||||||
basePath: string,
|
basePath: string,
|
||||||
|
|
@ -1350,7 +1351,7 @@ export async function showSmartEntry(
|
||||||
logWarning("guided", `STATE.md rebuild failed: ${(err as Error).message}`);
|
logWarning("guided", `STATE.md rebuild failed: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!runPlanV2Gate(ctx, basePath, state)) return;
|
if (!runPlanningFlowGate(ctx, basePath, state)) return;
|
||||||
|
|
||||||
if (!state.activeMilestone?.id) {
|
if (!state.activeMilestone?.id) {
|
||||||
// Guard: if a discuss session is already in flight, don't re-inject the prompt.
|
// Guard: if a discuss session is already in flight, don't re-inject the prompt.
|
||||||
|
|
@ -1642,7 +1643,7 @@ export async function showSmartEntry(
|
||||||
});
|
});
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
discardMilestone(basePath, milestoneId);
|
discardMilestone(basePath, milestoneId);
|
||||||
return showSmartEntry(ctx, pi, basePath, options);
|
return showWorkflowEntry(ctx, pi, basePath, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1680,7 +1681,7 @@ export async function showSmartEntry(
|
||||||
await fireStatusViaCommand(ctx);
|
await fireStatusViaCommand(ctx);
|
||||||
} else if (choice === "milestone_actions") {
|
} else if (choice === "milestone_actions") {
|
||||||
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
||||||
if (acted) return showSmartEntry(ctx, pi, basePath, options);
|
if (acted) return showWorkflowEntry(ctx, pi, basePath, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -1780,7 +1781,7 @@ export async function showSmartEntry(
|
||||||
await fireStatusViaCommand(ctx);
|
await fireStatusViaCommand(ctx);
|
||||||
} else if (choice === "milestone_actions") {
|
} else if (choice === "milestone_actions") {
|
||||||
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
||||||
if (acted) return showSmartEntry(ctx, pi, basePath, options);
|
if (acted) return showWorkflowEntry(ctx, pi, basePath, options);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1835,7 +1836,7 @@ export async function showSmartEntry(
|
||||||
await fireStatusViaCommand(ctx);
|
await fireStatusViaCommand(ctx);
|
||||||
} else if (choice === "milestone_actions") {
|
} else if (choice === "milestone_actions") {
|
||||||
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
||||||
if (acted) return showSmartEntry(ctx, pi, basePath, options);
|
if (acted) return showWorkflowEntry(ctx, pi, basePath, options);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1926,7 +1927,7 @@ export async function showSmartEntry(
|
||||||
await fireStatusViaCommand(ctx);
|
await fireStatusViaCommand(ctx);
|
||||||
} else if (choice === "milestone_actions") {
|
} else if (choice === "milestone_actions") {
|
||||||
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
||||||
if (acted) return showSmartEntry(ctx, pi, basePath, options);
|
if (acted) return showWorkflowEntry(ctx, pi, basePath, options);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1935,3 +1936,5 @@ export async function showSmartEntry(
|
||||||
const { fireStatusViaCommand } = await import("./commands.js");
|
const { fireStatusViaCommand } = await import("./commands.js");
|
||||||
await fireStatusViaCommand(ctx);
|
await fireStatusViaCommand(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const showSmartEntry = showWorkflowEntry;
|
||||||
|
|
|
||||||
|
|
@ -261,7 +261,7 @@ export async function showProjectInit(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write initial STATE.md so it exists before the first /gsd invocation.
|
// Write initial STATE.md so it exists before the first /gsd invocation.
|
||||||
// The explicit /gsd init path (ops.ts) returns without entering showSmartEntry(),
|
// The explicit /gsd init path (ops.ts) returns without entering showWorkflowEntry(),
|
||||||
// which would otherwise generate STATE.md at guided-flow.ts:1358.
|
// which would otherwise generate STATE.md at guided-flow.ts:1358.
|
||||||
try {
|
try {
|
||||||
const { deriveState } = await import("./state.js");
|
const { deriveState } = await import("./state.js");
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { gsdRoot } from "./paths.js";
|
import { gsdRoot } from "./paths.js";
|
||||||
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
|
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
|
||||||
import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js";
|
import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -93,7 +93,7 @@ export function emitJournalEvent(basePath: string, entry: JournalEntry): void {
|
||||||
// Silent failure — journal must never break auto-mode
|
// Silent failure — journal must never break auto-mode
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isUnifiedAuditEnabled()) return;
|
if (!isAuditEnvelopeEnabled()) return;
|
||||||
try {
|
try {
|
||||||
const causedBy = entry.causedBy
|
const causedBy = entry.causedBy
|
||||||
? `${entry.causedBy.flowId}:${entry.causedBy.seq}`
|
? `${entry.causedBy.flowId}:${entry.causedBy.seq}`
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { getAndClearSkills } from "./skill-telemetry.js";
|
||||||
import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
|
import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
|
||||||
import { parseUnitId } from "./unit-id.js";
|
import { parseUnitId } from "./unit-id.js";
|
||||||
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
|
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
|
||||||
import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js";
|
import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js";
|
||||||
import { getDatabase } from "./gsd-db.js";
|
import { getDatabase } from "./gsd-db.js";
|
||||||
|
|
||||||
// Re-export from shared — import directly from format-utils to avoid pulling
|
// Re-export from shared — import directly from format-utils to avoid pulling
|
||||||
|
|
@ -296,7 +296,7 @@ export function snapshotUnitMetrics(
|
||||||
// Background outcome recording for Bayesian learning
|
// Background outcome recording for Bayesian learning
|
||||||
recordUnitOutcome(unit).catch(() => { /* fire-and-forget */ });
|
recordUnitOutcome(unit).catch(() => { /* fire-and-forget */ });
|
||||||
|
|
||||||
if (isUnifiedAuditEnabled()) {
|
if (isAuditEnvelopeEnabled()) {
|
||||||
emitUokAuditEvent(
|
emitUokAuditEvent(
|
||||||
basePath,
|
basePath,
|
||||||
buildAuditEnvelope({
|
buildAuditEnvelope({
|
||||||
|
|
|
||||||
|
|
@ -231,9 +231,17 @@ export interface UokPreferences {
|
||||||
turn_action?: UokTurnActionMode;
|
turn_action?: UokTurnActionMode;
|
||||||
turn_push?: boolean;
|
turn_push?: boolean;
|
||||||
};
|
};
|
||||||
|
audit_envelope?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
/** @deprecated Use `audit_envelope` instead. */
|
||||||
audit_unified?: {
|
audit_unified?: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
planning_flow?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
/** @deprecated Use `planning_flow` instead. */
|
||||||
plan_v2?: {
|
plan_v2?: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
@ -289,7 +297,7 @@ export interface GSDPreferences {
|
||||||
post_unit_hooks?: PostUnitHookConfig[];
|
post_unit_hooks?: PostUnitHookConfig[];
|
||||||
pre_dispatch_hooks?: PreDispatchHookConfig[];
|
pre_dispatch_hooks?: PreDispatchHookConfig[];
|
||||||
dynamic_routing?: DynamicRoutingConfig;
|
dynamic_routing?: DynamicRoutingConfig;
|
||||||
/** Unified Orchestration Kernel controls (all flags default off). */
|
/** Orchestration kernel controls (all flags default off). */
|
||||||
uok?: UokPreferences;
|
uok?: UokPreferences;
|
||||||
/** Per-model capability overrides. Deep-merged with built-in profiles for capability-aware routing (ADR-004). */
|
/** Per-model capability overrides. Deep-merged with built-in profiles for capability-aware routing (ADR-004). */
|
||||||
modelOverrides?: Record<string, { capabilities?: Partial<ModelCapabilities> }>;
|
modelOverrides?: Record<string, { capabilities?: Partial<ModelCapabilities> }>;
|
||||||
|
|
|
||||||
|
|
@ -186,8 +186,10 @@ export function validatePreferences(preferences: GSDPreferences): {
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseEnabledBlock = (
|
const parseEnabledBlock = (
|
||||||
key: "legacy_fallback" | "gates" | "model_policy" | "execution_graph" | "audit_unified" | "plan_v2",
|
key: "legacy_fallback" | "gates" | "model_policy" | "execution_graph" | "audit_envelope" | "audit_unified" | "planning_flow" | "plan_v2",
|
||||||
|
targetKey?: "legacy_fallback" | "gates" | "model_policy" | "execution_graph" | "audit_envelope" | "planning_flow",
|
||||||
): void => {
|
): void => {
|
||||||
|
const normalizedTargetKey = targetKey ?? (key === "plan_v2" ? "planning_flow" : key);
|
||||||
const value = raw[key];
|
const value = raw[key];
|
||||||
if (value === undefined) return;
|
if (value === undefined) return;
|
||||||
if (typeof value !== "object" || value === null) {
|
if (typeof value !== "object" || value === null) {
|
||||||
|
|
@ -205,7 +207,7 @@ export function validatePreferences(preferences: GSDPreferences): {
|
||||||
warnings.push(`unknown uok.${key} key "${unk}" — ignored`);
|
warnings.push(`unknown uok.${key} key "${unk}" — ignored`);
|
||||||
}
|
}
|
||||||
if (Object.keys(parsed).length > 0) {
|
if (Object.keys(parsed).length > 0) {
|
||||||
valid[key] = parsed;
|
valid[normalizedTargetKey] = parsed;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -213,8 +215,16 @@ export function validatePreferences(preferences: GSDPreferences): {
|
||||||
parseEnabledBlock("gates");
|
parseEnabledBlock("gates");
|
||||||
parseEnabledBlock("model_policy");
|
parseEnabledBlock("model_policy");
|
||||||
parseEnabledBlock("execution_graph");
|
parseEnabledBlock("execution_graph");
|
||||||
parseEnabledBlock("audit_unified");
|
parseEnabledBlock("audit_envelope");
|
||||||
parseEnabledBlock("plan_v2");
|
if (raw.audit_unified !== undefined && raw.audit_envelope === undefined) {
|
||||||
|
warnings.push("uok.audit_unified is deprecated; use uok.audit_envelope");
|
||||||
|
parseEnabledBlock("audit_unified", "audit_envelope");
|
||||||
|
}
|
||||||
|
parseEnabledBlock("planning_flow");
|
||||||
|
if (raw.plan_v2 !== undefined && raw.planning_flow === undefined) {
|
||||||
|
warnings.push("uok.plan_v2 is deprecated; use uok.planning_flow");
|
||||||
|
parseEnabledBlock("plan_v2", "planning_flow");
|
||||||
|
}
|
||||||
|
|
||||||
if (raw.gitops !== undefined) {
|
if (raw.gitops !== undefined) {
|
||||||
if (typeof raw.gitops !== "object" || raw.gitops === null) {
|
if (typeof raw.gitops !== "object" || raw.gitops === null) {
|
||||||
|
|
@ -257,7 +267,9 @@ export function validatePreferences(preferences: GSDPreferences): {
|
||||||
"model_policy",
|
"model_policy",
|
||||||
"execution_graph",
|
"execution_graph",
|
||||||
"gitops",
|
"gitops",
|
||||||
|
"audit_envelope",
|
||||||
"audit_unified",
|
"audit_unified",
|
||||||
|
"planning_flow",
|
||||||
"plan_v2",
|
"plan_v2",
|
||||||
]);
|
]);
|
||||||
for (const key of Object.keys(raw)) {
|
for (const key of Object.keys(raw)) {
|
||||||
|
|
|
||||||
|
|
@ -400,11 +400,27 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
|
||||||
gitops: (base.uok?.gitops || override.uok?.gitops)
|
gitops: (base.uok?.gitops || override.uok?.gitops)
|
||||||
? { ...(base.uok?.gitops ?? {}), ...(override.uok?.gitops ?? {}) }
|
? { ...(base.uok?.gitops ?? {}), ...(override.uok?.gitops ?? {}) }
|
||||||
: undefined,
|
: undefined,
|
||||||
audit_unified: (base.uok?.audit_unified || override.uok?.audit_unified)
|
audit_envelope: (
|
||||||
? { ...(base.uok?.audit_unified ?? {}), ...(override.uok?.audit_unified ?? {}) }
|
base.uok?.audit_envelope
|
||||||
|
|| base.uok?.audit_unified
|
||||||
|
|| override.uok?.audit_envelope
|
||||||
|
|| override.uok?.audit_unified
|
||||||
|
)
|
||||||
|
? {
|
||||||
|
...(base.uok?.audit_envelope ?? base.uok?.audit_unified ?? {}),
|
||||||
|
...(override.uok?.audit_envelope ?? override.uok?.audit_unified ?? {}),
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
plan_v2: (base.uok?.plan_v2 || override.uok?.plan_v2)
|
planning_flow: (
|
||||||
? { ...(base.uok?.plan_v2 ?? {}), ...(override.uok?.plan_v2 ?? {}) }
|
base.uok?.planning_flow
|
||||||
|
|| base.uok?.plan_v2
|
||||||
|
|| override.uok?.planning_flow
|
||||||
|
|| override.uok?.plan_v2
|
||||||
|
)
|
||||||
|
? {
|
||||||
|
...(base.uok?.planning_flow ?? base.uok?.plan_v2 ?? {}),
|
||||||
|
...(override.uok?.planning_flow ?? override.uok?.plan_v2 ?? {}),
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
// GSD Extension — Unified Rule Registry
|
// GSD Extension — Rule Registry
|
||||||
//
|
//
|
||||||
// Holds all dispatch rules and hooks as a flat list of UnifiedRule objects.
|
// Holds all dispatch rules and hooks as a flat list of RegistryRule objects.
|
||||||
// Provides evaluation methods for each phase (dispatch, post-unit, pre-dispatch)
|
// Provides evaluation methods for each phase (dispatch, post-unit, pre-dispatch)
|
||||||
// and encapsulates mutable hook state as instance fields.
|
// and encapsulates mutable hook state as instance fields.
|
||||||
//
|
//
|
||||||
// A module-level singleton accessor allows existing code to migrate incrementally.
|
// A module-level singleton accessor allows existing code to migrate incrementally.
|
||||||
|
|
||||||
import { logWarning } from "./workflow-logger.js";
|
import { logWarning } from "./workflow-logger.js";
|
||||||
import type { UnifiedRule, RulePhase } from "./rule-types.js";
|
import type { RegistryRule, RulePhase } from "./rule-types.js";
|
||||||
import type { DispatchAction, DispatchContext, DispatchRule } from "./auto-dispatch.js";
|
import type { DispatchAction, DispatchContext, DispatchRule } from "./auto-dispatch.js";
|
||||||
import type {
|
import type {
|
||||||
PostUnitHookConfig,
|
PostUnitHookConfig,
|
||||||
|
|
@ -39,10 +39,10 @@ export function resolveHookArtifactPath(basePath: string, unitId: string, artifa
|
||||||
// ─── Dispatch Rule Conversion ──────────────────────────────────────────────
|
// ─── Dispatch Rule Conversion ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert an array of DispatchRule objects to UnifiedRule[] format.
|
* Convert an array of DispatchRule objects to RegistryRule[] format.
|
||||||
* Preserves exact array order — dispatch is order-dependent (first-match-wins).
|
* Preserves exact array order — dispatch is order-dependent (first-match-wins).
|
||||||
*/
|
*/
|
||||||
export function convertDispatchRules(rules: DispatchRule[]): UnifiedRule[] {
|
export function convertDispatchRules(rules: DispatchRule[]): RegistryRule[] {
|
||||||
return rules.map((rule) => ({
|
return rules.map((rule) => ({
|
||||||
name: rule.name,
|
name: rule.name,
|
||||||
when: "dispatch" as const,
|
when: "dispatch" as const,
|
||||||
|
|
@ -59,7 +59,7 @@ const HOOK_STATE_FILE = "hook-state.json";
|
||||||
|
|
||||||
export class RuleRegistry {
|
export class RuleRegistry {
|
||||||
/** Static dispatch rules provided at construction time. */
|
/** Static dispatch rules provided at construction time. */
|
||||||
private readonly dispatchRules: UnifiedRule[];
|
private readonly dispatchRules: RegistryRule[];
|
||||||
|
|
||||||
// ── Mutable hook state (encapsulated, not module-level) ──────────────
|
// ── Mutable hook state (encapsulated, not module-level) ──────────────
|
||||||
|
|
||||||
|
|
@ -73,7 +73,7 @@ export class RuleRegistry {
|
||||||
retryPending: boolean = false;
|
retryPending: boolean = false;
|
||||||
retryTrigger: { unitType: string; unitId: string; retryArtifact: string } | null = null;
|
retryTrigger: { unitType: string; unitId: string; retryArtifact: string } | null = null;
|
||||||
|
|
||||||
constructor(dispatchRules: UnifiedRule[]) {
|
constructor(dispatchRules: RegistryRule[]) {
|
||||||
this.dispatchRules = dispatchRules;
|
this.dispatchRules = dispatchRules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,8 +83,8 @@ export class RuleRegistry {
|
||||||
* Returns all rules: static dispatch rules + dynamically loaded hook rules.
|
* Returns all rules: static dispatch rules + dynamically loaded hook rules.
|
||||||
* Hook rules are loaded fresh from preferences on each call (not cached).
|
* Hook rules are loaded fresh from preferences on each call (not cached).
|
||||||
*/
|
*/
|
||||||
listRules(): UnifiedRule[] {
|
listRules(): RegistryRule[] {
|
||||||
const rules: UnifiedRule[] = [...this.dispatchRules];
|
const rules: RegistryRule[] = [...this.dispatchRules];
|
||||||
|
|
||||||
// Convert post-unit hooks to unified rules
|
// Convert post-unit hooks to unified rules
|
||||||
const postHooks = resolvePostUnitHooks();
|
const postHooks = resolvePostUnitHooks();
|
||||||
|
|
@ -575,7 +575,7 @@ export function setRegistry(r: RuleRegistry): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create and set the singleton registry with the given dispatch rules. */
|
/** Create and set the singleton registry with the given dispatch rules. */
|
||||||
export function initRegistry(dispatchRules: UnifiedRule[]): RuleRegistry {
|
export function initRegistry(dispatchRules: RegistryRule[]): RuleRegistry {
|
||||||
const registry = new RuleRegistry(dispatchRules);
|
const registry = new RuleRegistry(dispatchRules);
|
||||||
setRegistry(registry);
|
setRegistry(registry);
|
||||||
return registry;
|
return registry;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// GSD Extension — Unified Rule Type Definitions
|
// GSD Extension — Rule Type Definitions
|
||||||
//
|
//
|
||||||
// Every dispatch rule and hook is expressed as a `UnifiedRule` with a
|
// Every dispatch rule and hook is expressed as a `RegistryRule` with a
|
||||||
// consistent when/where/then shape. This file defines the type system;
|
// consistent when/where/then shape. This file defines the type system;
|
||||||
// the `RuleRegistry` class in rule-registry.ts holds instances at runtime.
|
// the `RuleRegistry` class in rule-registry.ts holds instances at runtime.
|
||||||
|
|
||||||
|
|
@ -36,13 +36,13 @@ export interface RuleLifecycle {
|
||||||
idempotency_key?: string;
|
idempotency_key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Unified Rule ───────────────────────────────────────────────────────────
|
// ─── Registry Rule ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single entry in the rule registry. Dispatch rules, post-unit hooks,
|
* A single entry in the rule registry. Dispatch rules, post-unit hooks,
|
||||||
* and pre-dispatch hooks all share this shape.
|
* and pre-dispatch hooks all share this shape.
|
||||||
*/
|
*/
|
||||||
export interface UnifiedRule {
|
export interface RegistryRule {
|
||||||
/** Stable human-readable identifier (existing names preserved per D005). */
|
/** Stable human-readable identifier (existing names preserved per D005). */
|
||||||
name: string;
|
name: string;
|
||||||
/** Which phase/event this rule responds to. */
|
/** Which phase/event this rule responds to. */
|
||||||
|
|
|
||||||
|
|
@ -222,14 +222,14 @@ function createLockCompromisedHandler(lockFilePath: string): () => void {
|
||||||
const elapsed = Date.now() - _lockAcquiredAt;
|
const elapsed = Date.now() - _lockAcquiredAt;
|
||||||
if (elapsed < 1_800_000) {
|
if (elapsed < 1_800_000) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`[gsd] Lock heartbeat caught up after ${Math.round(elapsed / 1000)}s — long LLM call, no action needed.\n`,
|
`[forge] Lock heartbeat caught up after ${Math.round(elapsed / 1000)}s — long LLM call, no action needed.\n`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const existing = readExistingLockDataWithRetry(lockFilePath);
|
const existing = readExistingLockDataWithRetry(lockFilePath);
|
||||||
if (existing && existing.pid === process.pid) {
|
if (existing && existing.pid === process.pid) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`[gsd] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — lock file still owned by PID ${process.pid}, treating as false positive.\n`,
|
`[forge] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — lock file still owned by PID ${process.pid}, treating as false positive.\n`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -463,7 +463,7 @@ export function getSessionLockStatus(basePath: string): SessionLockStatus {
|
||||||
const result = acquireSessionLock(basePath);
|
const result = acquireSessionLock(basePath);
|
||||||
if (result.acquired) {
|
if (result.acquired) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`[gsd] Lock recovered after onCompromised — lock file PID matched, re-acquired.\n`,
|
`[forge] Lock recovered after onCompromised — lock file PID matched, re-acquired.\n`,
|
||||||
);
|
);
|
||||||
return { valid: true, recovered: true };
|
return { valid: true, recovered: true };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,9 @@ uok:
|
||||||
enabled: false
|
enabled: false
|
||||||
turn_action: status-only
|
turn_action: status-only
|
||||||
turn_push: false
|
turn_push: false
|
||||||
audit_unified:
|
audit_envelope:
|
||||||
enabled: false
|
enabled: false
|
||||||
plan_v2:
|
planning_flow:
|
||||||
enabled: false
|
enabled: false
|
||||||
auto_visualize:
|
auto_visualize:
|
||||||
auto_report:
|
auto_report:
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ test("bootstrapAutoSession snapshots ctx.model before guided-flow entry (#2829)"
|
||||||
const snapshotIdx = source.indexOf("const startModelSnapshot = manualSessionOverride");
|
const snapshotIdx = source.indexOf("const startModelSnapshot = manualSessionOverride");
|
||||||
assert.ok(snapshotIdx > -1, "auto-start.ts should snapshot model at bootstrap start");
|
assert.ok(snapshotIdx > -1, "auto-start.ts should snapshot model at bootstrap start");
|
||||||
|
|
||||||
const firstDiscussIdx = source.indexOf('await showSmartEntry(ctx, pi, base, { step: requestedStepMode });');
|
const firstDiscussIdx = source.indexOf('await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });');
|
||||||
assert.ok(firstDiscussIdx > -1, "auto-start.ts should route through showSmartEntry during guided flow");
|
assert.ok(firstDiscussIdx > -1, "auto-start.ts should route through showWorkflowEntry during guided flow");
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
snapshotIdx < firstDiscussIdx,
|
snapshotIdx < firstDiscussIdx,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
*
|
*
|
||||||
* 1. The survivor branch check included needs-discussion, so a branch
|
* 1. The survivor branch check included needs-discussion, so a branch
|
||||||
* created by a prior failed bootstrap caused hasSurvivorBranch = true,
|
* created by a prior failed bootstrap caused hasSurvivorBranch = true,
|
||||||
* skipping all showSmartEntry calls.
|
* skipping all showWorkflowEntry calls.
|
||||||
*
|
*
|
||||||
* 2. No needs-discussion handler existed in the !hasSurvivorBranch block,
|
* 2. No needs-discussion handler existed in the !hasSurvivorBranch block,
|
||||||
* so the phase fell through to auto-mode which immediately stopped
|
* so the phase fell through to auto-mode which immediately stopped
|
||||||
|
|
@ -118,12 +118,12 @@ describe("auto-start-needs-discussion (#1726)", () => {
|
||||||
const source = readAutoStartSource();
|
const source = readAutoStartSource();
|
||||||
|
|
||||||
// After the pre-planning handler, there should be a needs-discussion handler
|
// After the pre-planning handler, there should be a needs-discussion handler
|
||||||
// that calls showSmartEntry
|
// that calls showWorkflowEntry
|
||||||
const needsDiscussionHandler = source.match(
|
const needsDiscussionHandler = source.match(
|
||||||
/if\s*\(state\.phase\s*===\s*"needs-discussion"\)\s*\{[^}]*showSmartEntry/s,
|
/if\s*\(state\.phase\s*===\s*"needs-discussion"\)\s*\{[^}]*showWorkflowEntry/s,
|
||||||
);
|
);
|
||||||
assert.ok(!!needsDiscussionHandler,
|
assert.ok(!!needsDiscussionHandler,
|
||||||
"needs-discussion handler calling showSmartEntry must exist in !hasSurvivorBranch block");
|
"needs-discussion handler calling showWorkflowEntry must exist in !hasSurvivorBranch block");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("4. needs-discussion handler has abort path", () => {
|
test("4. needs-discussion handler has abort path", () => {
|
||||||
|
|
@ -133,7 +133,7 @@ describe("auto-start-needs-discussion (#1726)", () => {
|
||||||
// if discussion didn't promote the draft
|
// if discussion didn't promote the draft
|
||||||
assert.ok(
|
assert.ok(
|
||||||
source.includes('postState.phase !== "needs-discussion"'),
|
source.includes('postState.phase !== "needs-discussion"'),
|
||||||
"needs-discussion handler must check if phase advanced after showSmartEntry",
|
"needs-discussion handler must check if phase advanced after showWorkflowEntry",
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
source.includes("milestone draft was not promoted"),
|
source.includes("milestone draft was not promoted"),
|
||||||
|
|
@ -157,12 +157,12 @@ describe("auto-start-needs-discussion (#1726)", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("6. No infinite loop: needs-discussion always routes to showSmartEntry", () => {
|
test("6. No infinite loop: needs-discussion always routes to showWorkflowEntry", () => {
|
||||||
const source = readAutoStartSource();
|
const source = readAutoStartSource();
|
||||||
|
|
||||||
// Verify needs-discussion does NOT appear in auto-dispatch trigger conditions
|
// Verify needs-discussion does NOT appear in auto-dispatch trigger conditions
|
||||||
// within auto-start.ts. The only place needs-discussion should appear is in
|
// within auto-start.ts. The only place needs-discussion should appear is in
|
||||||
// the showSmartEntry routing block.
|
// the showWorkflowEntry routing block.
|
||||||
const survivorSection = source.match(
|
const survivorSection = source.match(
|
||||||
/\/\/ Milestone branch recovery.*?let hasSurvivorBranch = false;[\s\S]*?if\s*\([^)]*state\.phase[^)]*\)\s*\{/,
|
/\/\/ Milestone branch recovery.*?let hasSurvivorBranch = false;[\s\S]*?if\s*\([^)]*state\.phase[^)]*\)\s*\{/,
|
||||||
);
|
);
|
||||||
|
|
@ -187,16 +187,16 @@ describe("auto-start-needs-discussion (#1726)", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("7. Survivor branch + needs-discussion routes to showSmartEntry", () => {
|
test("7. Survivor branch + needs-discussion routes to showWorkflowEntry", () => {
|
||||||
const source = readAutoStartSource();
|
const source = readAutoStartSource();
|
||||||
|
|
||||||
// When hasSurvivorBranch is true AND phase is needs-discussion, the code
|
// When hasSurvivorBranch is true AND phase is needs-discussion, the code
|
||||||
// must route to showSmartEntry instead of falling through to auto-mode.
|
// must route to showWorkflowEntry instead of falling through to auto-mode.
|
||||||
const survivorNeedsDiscussion = source.match(
|
const survivorNeedsDiscussion = source.match(
|
||||||
/if\s*\(hasSurvivorBranch\s*&&\s*state\.phase\s*===\s*"needs-discussion"\)\s*\{[^}]*showSmartEntry/s,
|
/if\s*\(hasSurvivorBranch\s*&&\s*state\.phase\s*===\s*"needs-discussion"\)\s*\{[^}]*showWorkflowEntry/s,
|
||||||
);
|
);
|
||||||
assert.ok(!!survivorNeedsDiscussion,
|
assert.ok(!!survivorNeedsDiscussion,
|
||||||
"hasSurvivorBranch && needs-discussion must route to showSmartEntry");
|
"hasSurvivorBranch && needs-discussion must route to showWorkflowEntry");
|
||||||
|
|
||||||
// Verify the handler checks if the discussion succeeded
|
// Verify the handler checks if the discussion succeeded
|
||||||
const handlerBlock = source.match(
|
const handlerBlock = source.match(
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ describe('register-extension crash handler secondary fixes (#3348)', () => {
|
||||||
// The original #3696 fix replaced "throw err" with a log-and-continue.
|
// The original #3696 fix replaced "throw err" with a log-and-continue.
|
||||||
// The secondary fix replaces that with writeCrashLog + process.exit(1).
|
// The secondary fix replaces that with writeCrashLog + process.exit(1).
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!registerExtSrc.includes('process.stderr.write(`[gsd] uncaught extension error (non-fatal)'),
|
!registerExtSrc.includes('process.stderr.write(`[forge] uncaught extension error (non-fatal)'),
|
||||||
'_gsdEpipeGuard should NOT log errors as non-fatal and continue',
|
'_gsdEpipeGuard should NOT log errors as non-fatal and continue',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// GSD Extension — Rule Registry Tests
|
// GSD Extension — Rule Registry Tests
|
||||||
//
|
//
|
||||||
// Tests the RuleRegistry class, UnifiedRule types, singleton accessors,
|
// Tests the RuleRegistry class, RegistryRule types, singleton accessors,
|
||||||
// and evaluation methods using mock rules.
|
// and evaluation methods using mock rules.
|
||||||
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
@ -14,14 +14,14 @@ import {
|
||||||
convertDispatchRules,
|
convertDispatchRules,
|
||||||
getOrCreateRegistry,
|
getOrCreateRegistry,
|
||||||
} from "../rule-registry.ts";
|
} from "../rule-registry.ts";
|
||||||
import type { UnifiedRule } from "../rule-types.ts";
|
import type { RegistryRule } from "../rule-types.ts";
|
||||||
import type { DispatchAction, DispatchContext } from "../auto-dispatch.ts";
|
import type { DispatchAction, DispatchContext } from "../auto-dispatch.ts";
|
||||||
import { DISPATCH_RULES, getDispatchRuleNames } from "../auto-dispatch.ts";
|
import { DISPATCH_RULES, getDispatchRuleNames } from "../auto-dispatch.ts";
|
||||||
import type { GSDState } from "../types.ts";
|
import type { GSDState } from "../types.ts";
|
||||||
|
|
||||||
// ─── Mock Rule Factories ──────────────────────────────────────────────────
|
// ─── Mock Rule Factories ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function mockDispatchRule(name: string, matchPhase: string): UnifiedRule {
|
function mockDispatchRule(name: string, matchPhase: string): RegistryRule {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
when: "dispatch",
|
when: "dispatch",
|
||||||
|
|
@ -69,7 +69,7 @@ describe("RuleRegistry", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("construct with dispatch rules, listRules returns them", () => {
|
test("construct with dispatch rules, listRules returns them", () => {
|
||||||
const rules: UnifiedRule[] = [
|
const rules: RegistryRule[] = [
|
||||||
mockDispatchRule("rule-a", "planning"),
|
mockDispatchRule("rule-a", "planning"),
|
||||||
mockDispatchRule("rule-b", "executing"),
|
mockDispatchRule("rule-b", "executing"),
|
||||||
mockDispatchRule("rule-c", "complete"),
|
mockDispatchRule("rule-c", "complete"),
|
||||||
|
|
@ -86,7 +86,7 @@ describe("RuleRegistry", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("listRules returns correct fields on each rule", () => {
|
test("listRules returns correct fields on each rule", () => {
|
||||||
const rules: UnifiedRule[] = [
|
const rules: RegistryRule[] = [
|
||||||
mockDispatchRule("check-fields", "planning"),
|
mockDispatchRule("check-fields", "planning"),
|
||||||
];
|
];
|
||||||
const registry = new RuleRegistry(rules);
|
const registry = new RuleRegistry(rules);
|
||||||
|
|
@ -102,7 +102,7 @@ describe("RuleRegistry", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("evaluateDispatch returns first matching rule", async () => {
|
test("evaluateDispatch returns first matching rule", async () => {
|
||||||
const rules: UnifiedRule[] = [
|
const rules: RegistryRule[] = [
|
||||||
mockDispatchRule("rule-planning", "planning"),
|
mockDispatchRule("rule-planning", "planning"),
|
||||||
mockDispatchRule("rule-executing", "executing"),
|
mockDispatchRule("rule-executing", "executing"),
|
||||||
mockDispatchRule("rule-complete", "complete"),
|
mockDispatchRule("rule-complete", "complete"),
|
||||||
|
|
@ -119,7 +119,7 @@ describe("RuleRegistry", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("evaluateDispatch returns stop when no rule matches", async () => {
|
test("evaluateDispatch returns stop when no rule matches", async () => {
|
||||||
const rules: UnifiedRule[] = [
|
const rules: RegistryRule[] = [
|
||||||
mockDispatchRule("only-planning", "planning"),
|
mockDispatchRule("only-planning", "planning"),
|
||||||
];
|
];
|
||||||
const registry = new RuleRegistry(rules);
|
const registry = new RuleRegistry(rules);
|
||||||
|
|
@ -133,7 +133,7 @@ describe("RuleRegistry", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("evaluateDispatch works with async where predicate", async () => {
|
test("evaluateDispatch works with async where predicate", async () => {
|
||||||
const asyncRule: UnifiedRule = {
|
const asyncRule: RegistryRule = {
|
||||||
name: "async-rule",
|
name: "async-rule",
|
||||||
when: "dispatch",
|
when: "dispatch",
|
||||||
evaluation: "first-match",
|
evaluation: "first-match",
|
||||||
|
|
@ -227,7 +227,7 @@ describe("RuleRegistry", () => {
|
||||||
|
|
||||||
test("evaluateDispatch respects rule order (first match wins)", async () => {
|
test("evaluateDispatch respects rule order (first match wins)", async () => {
|
||||||
// Both rules match "planning" but rule-first should win
|
// Both rules match "planning" but rule-first should win
|
||||||
const ruleFirst: UnifiedRule = {
|
const ruleFirst: RegistryRule = {
|
||||||
name: "rule-first",
|
name: "rule-first",
|
||||||
when: "dispatch",
|
when: "dispatch",
|
||||||
evaluation: "first-match",
|
evaluation: "first-match",
|
||||||
|
|
@ -239,7 +239,7 @@ describe("RuleRegistry", () => {
|
||||||
},
|
},
|
||||||
then: () => {},
|
then: () => {},
|
||||||
};
|
};
|
||||||
const ruleSecond: UnifiedRule = {
|
const ruleSecond: RegistryRule = {
|
||||||
name: "rule-second",
|
name: "rule-second",
|
||||||
when: "dispatch",
|
when: "dispatch",
|
||||||
evaluation: "first-match",
|
evaluation: "first-match",
|
||||||
|
|
@ -264,7 +264,7 @@ describe("RuleRegistry", () => {
|
||||||
|
|
||||||
// ── Dispatch rule conversion tests ─────────────────────────────────
|
// ── Dispatch rule conversion tests ─────────────────────────────────
|
||||||
|
|
||||||
test("convertDispatchRules produces correct count of UnifiedRule objects", () => {
|
test("convertDispatchRules produces correct count of RegistryRule objects", () => {
|
||||||
const converted = convertDispatchRules(DISPATCH_RULES);
|
const converted = convertDispatchRules(DISPATCH_RULES);
|
||||||
assert.deepStrictEqual(converted.length, DISPATCH_RULES.length, `convertDispatchRules produces ${DISPATCH_RULES.length} rules`);
|
assert.deepStrictEqual(converted.length, DISPATCH_RULES.length, `convertDispatchRules produces ${DISPATCH_RULES.length} rules`);
|
||||||
});
|
});
|
||||||
|
|
@ -386,7 +386,7 @@ describe("RuleRegistry", () => {
|
||||||
// ── matchedRule provenance (S02 journal support) ───────────────────
|
// ── matchedRule provenance (S02 journal support) ───────────────────
|
||||||
|
|
||||||
test("evaluateDispatch result includes matchedRule on dispatch match", async () => {
|
test("evaluateDispatch result includes matchedRule on dispatch match", async () => {
|
||||||
const rules: UnifiedRule[] = [
|
const rules: RegistryRule[] = [
|
||||||
mockDispatchRule("my-planning-rule", "planning"),
|
mockDispatchRule("my-planning-rule", "planning"),
|
||||||
];
|
];
|
||||||
const registry = new RuleRegistry(rules);
|
const registry = new RuleRegistry(rules);
|
||||||
|
|
@ -398,7 +398,7 @@ describe("RuleRegistry", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("evaluateDispatch result includes matchedRule '<no-match>' on fallback stop", async () => {
|
test("evaluateDispatch result includes matchedRule '<no-match>' on fallback stop", async () => {
|
||||||
const rules: UnifiedRule[] = [
|
const rules: RegistryRule[] = [
|
||||||
mockDispatchRule("only-planning", "planning"),
|
mockDispatchRule("only-planning", "planning"),
|
||||||
];
|
];
|
||||||
const registry = new RuleRegistry(rules);
|
const registry = new RuleRegistry(rules);
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ const guidedFlowSource = readFileSync(
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
guidedFlowSource.includes('state.phase === "needs-discussion"'),
|
guidedFlowSource.includes('state.phase === "needs-discussion"'),
|
||||||
"guided-flow.ts should have 'needs-discussion' phase check in showSmartEntry",
|
"guided-flow.ts should have 'needs-discussion' phase check in showWorkflowEntry",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check the branch has draft-aware menu options
|
// Check the branch has draft-aware menu options
|
||||||
|
|
|
||||||
|
|
@ -76,13 +76,13 @@ const { assertTrue, assertEq, report } = createTestContext();
|
||||||
// Simulate the decision logic after the fix:
|
// Simulate the decision logic after the fix:
|
||||||
// if (hasSurvivorBranch && state.phase === "complete") -> finalize
|
// if (hasSurvivorBranch && state.phase === "complete") -> finalize
|
||||||
// if (hasSurvivorBranch && state.phase === "needs-discussion") -> discuss
|
// if (hasSurvivorBranch && state.phase === "needs-discussion") -> discuss
|
||||||
// if (!hasSurvivorBranch && state.phase === "complete") -> showSmartEntry
|
// if (!hasSurvivorBranch && state.phase === "complete") -> showWorkflowEntry
|
||||||
|
|
||||||
const scenarios = [
|
const scenarios = [
|
||||||
{ hasSurvivorBranch: true, phase: "complete", expected: "finalize" },
|
{ hasSurvivorBranch: true, phase: "complete", expected: "finalize" },
|
||||||
{ hasSurvivorBranch: true, phase: "needs-discussion", expected: "discuss" },
|
{ hasSurvivorBranch: true, phase: "needs-discussion", expected: "discuss" },
|
||||||
{ hasSurvivorBranch: true, phase: "pre-planning", expected: "continue" },
|
{ hasSurvivorBranch: true, phase: "pre-planning", expected: "continue" },
|
||||||
{ hasSurvivorBranch: false, phase: "complete", expected: "showSmartEntry" },
|
{ hasSurvivorBranch: false, phase: "complete", expected: "showWorkflowEntry" },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { hasSurvivorBranch, phase, expected } of scenarios) {
|
for (const { hasSurvivorBranch, phase, expected } of scenarios) {
|
||||||
|
|
@ -92,7 +92,7 @@ const { assertTrue, assertEq, report } = createTestContext();
|
||||||
} else if (hasSurvivorBranch && phase === "needs-discussion") {
|
} else if (hasSurvivorBranch && phase === "needs-discussion") {
|
||||||
result = "discuss";
|
result = "discuss";
|
||||||
} else if (!hasSurvivorBranch && (!phase || phase === "complete")) {
|
} else if (!hasSurvivorBranch && (!phase || phase === "complete")) {
|
||||||
result = "showSmartEntry";
|
result = "showWorkflowEntry";
|
||||||
} else {
|
} else {
|
||||||
result = "continue";
|
result = "continue";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { emitJournalEvent } from "../journal.ts";
|
||||||
import { saveActivityLog } from "../activity-log.ts";
|
import { saveActivityLog } from "../activity-log.ts";
|
||||||
import { initMetrics, resetMetrics, snapshotUnitMetrics } from "../metrics.ts";
|
import { initMetrics, resetMetrics, snapshotUnitMetrics } from "../metrics.ts";
|
||||||
import { setLogBasePath, logWarning } from "../workflow-logger.ts";
|
import { setLogBasePath, logWarning } from "../workflow-logger.ts";
|
||||||
import { setUnifiedAuditEnabled } from "../uok/audit-toggle.ts";
|
import { setAuditEnvelopeEnabled } from "../uok/audit-toggle.ts";
|
||||||
|
|
||||||
function readAuditEvents(basePath: string): Array<Record<string, unknown>> {
|
function readAuditEvents(basePath: string): Array<Record<string, unknown>> {
|
||||||
const file = join(basePath, ".gsd", "audit", "events.jsonl");
|
const file = join(basePath, ".gsd", "audit", "events.jsonl");
|
||||||
|
|
@ -27,9 +27,9 @@ function makeMockContext(entries: unknown[]): any {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test("unified audit plane bridges journal/activity/metrics/workflow logger into audit envelope log", () => {
|
test("audit envelope bridges journal/activity/metrics/workflow logger into audit event log", () => {
|
||||||
const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-"));
|
const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-"));
|
||||||
setUnifiedAuditEnabled(true);
|
setAuditEnvelopeEnabled(true);
|
||||||
try {
|
try {
|
||||||
emitJournalEvent(basePath, {
|
emitJournalEvent(basePath, {
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
|
|
@ -77,15 +77,15 @@ test("unified audit plane bridges journal/activity/metrics/workflow logger into
|
||||||
assert.ok(types.has("unit-metrics-snapshot"));
|
assert.ok(types.has("unit-metrics-snapshot"));
|
||||||
assert.ok(types.has("workflow-log-warn"));
|
assert.ok(types.has("workflow-log-warn"));
|
||||||
} finally {
|
} finally {
|
||||||
setUnifiedAuditEnabled(false);
|
setAuditEnvelopeEnabled(false);
|
||||||
resetMetrics();
|
resetMetrics();
|
||||||
rmSync(basePath, { recursive: true, force: true });
|
rmSync(basePath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unified audit bridge is disabled when toggle is off", () => {
|
test("audit envelope bridge is disabled when toggle is off", () => {
|
||||||
const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-off-"));
|
const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-off-"));
|
||||||
setUnifiedAuditEnabled(false);
|
setAuditEnvelopeEnabled(false);
|
||||||
try {
|
try {
|
||||||
emitJournalEvent(basePath, {
|
emitJournalEvent(basePath, {
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
|
|
|
||||||
|
|
@ -88,17 +88,17 @@ test.afterEach(() => {
|
||||||
tempDirs.clear();
|
tempDirs.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guided flow enforces plan-v2 gate before execution-oriented dispatch", () => {
|
test("guided flow enforces planning-flow gate before execution-oriented dispatch", () => {
|
||||||
const source = readFileSync(join(gsdDir, "guided-flow.ts"), "utf-8");
|
const source = readFileSync(join(gsdDir, "guided-flow.ts"), "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
source.includes("needsPlanV2Gate") &&
|
source.includes("needsPlanningFlowGate") &&
|
||||||
source.includes("ensurePlanV2Graph") &&
|
source.includes("ensurePlanningFlowGraph") &&
|
||||||
source.includes("Plan gate failed-closed"),
|
source.includes("Plan gate failed-closed"),
|
||||||
"guided flow should fail-closed when plan-v2 graph compilation fails",
|
"guided flow should fail-closed when planning-flow graph compilation fails",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plan-v2 gate fails closed for execution phase when finalized context is missing", () => {
|
test("planning-flow gate fails closed for execution phase when finalized context is missing", () => {
|
||||||
const basePath = createBasePath();
|
const basePath = createBasePath();
|
||||||
seedGraphRows();
|
seedGraphRows();
|
||||||
|
|
||||||
|
|
@ -109,7 +109,7 @@ test("plan-v2 gate fails closed for execution phase when finalized context is mi
|
||||||
assert.match(compiled.reason ?? "", /CONTEXT\.md/i);
|
assert.match(compiled.reason ?? "", /CONTEXT\.md/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plan-v2 compiler writes pipeline metadata for clarify/research/draft stages", () => {
|
test("planning-flow compiler writes pipeline metadata for clarify/research/draft stages", () => {
|
||||||
const basePath = createBasePath();
|
const basePath = createBasePath();
|
||||||
seedGraphRows();
|
seedGraphRows();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ test("uok preferences validate nested flags and turn_action", () => {
|
||||||
turn_action: "status-only",
|
turn_action: "status-only",
|
||||||
turn_push: false,
|
turn_push: false,
|
||||||
},
|
},
|
||||||
audit_unified: { enabled: true },
|
audit_envelope: { enabled: true },
|
||||||
plan_v2: { enabled: true },
|
planning_flow: { enabled: true },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ test("uok preferences validate nested flags and turn_action", () => {
|
||||||
assert.equal(result.preferences.uok?.enabled, true);
|
assert.equal(result.preferences.uok?.enabled, true);
|
||||||
assert.equal(result.preferences.uok?.legacy_fallback?.enabled, false);
|
assert.equal(result.preferences.uok?.legacy_fallback?.enabled, false);
|
||||||
assert.equal(result.preferences.uok?.gitops?.turn_action, "status-only");
|
assert.equal(result.preferences.uok?.gitops?.turn_action, "status-only");
|
||||||
assert.equal(result.preferences.uok?.plan_v2?.enabled, true);
|
assert.equal(result.preferences.uok?.planning_flow?.enabled, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uok preferences reject invalid turn_action", () => {
|
test("uok preferences reject invalid turn_action", () => {
|
||||||
|
|
@ -40,3 +40,27 @@ test("uok preferences reject invalid turn_action", () => {
|
||||||
|
|
||||||
assert.ok(result.errors.some((e) => e.includes("uok.gitops.turn_action")));
|
assert.ok(result.errors.some((e) => e.includes("uok.gitops.turn_action")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uok preferences accept deprecated plan_v2 alias", () => {
|
||||||
|
const result = validatePreferences({
|
||||||
|
uok: {
|
||||||
|
plan_v2: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.equal(result.preferences.uok?.planning_flow?.enabled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uok preferences accept deprecated audit_unified alias", () => {
|
||||||
|
const result = validatePreferences({
|
||||||
|
uok: {
|
||||||
|
audit_unified: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.equal(result.preferences.uok?.audit_envelope?.enabled, true);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const { assertTrue, assertMatch, assertNoMatch, report } = createTestContext();
|
||||||
// ─── #2942: Zombie .gsd state skips init wizard ─────────────────────────────
|
// ─── #2942: Zombie .gsd state skips init wizard ─────────────────────────────
|
||||||
//
|
//
|
||||||
// A partially initialized .gsd/ (symlink exists but no PREFERENCES.md or
|
// A partially initialized .gsd/ (symlink exists but no PREFERENCES.md or
|
||||||
// milestones/) causes the init wizard gate in showSmartEntry to be skipped,
|
// milestones/) causes the init wizard gate in showWorkflowEntry to be skipped,
|
||||||
// resulting in an uninitialized project session.
|
// resulting in an uninitialized project session.
|
||||||
|
|
||||||
console.log("\n=== #2942: zombie .gsd state must not skip init wizard ===");
|
console.log("\n=== #2942: zombie .gsd state must not skip init wizard ===");
|
||||||
|
|
@ -20,11 +20,11 @@ const guidedFlowSrc = readFileSync(
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find the showSmartEntry function
|
// Find the showWorkflowEntry function
|
||||||
const smartEntryIdx = guidedFlowSrc.indexOf("export async function showSmartEntry(");
|
const smartEntryIdx = guidedFlowSrc.indexOf("export async function showWorkflowEntry(");
|
||||||
assertTrue(smartEntryIdx >= 0, "guided-flow.ts defines showSmartEntry");
|
assertTrue(smartEntryIdx >= 0, "guided-flow.ts defines showWorkflowEntry");
|
||||||
|
|
||||||
// Extract the region between showSmartEntry and the first showProjectInit call
|
// Extract the region between showWorkflowEntry and the first showProjectInit call
|
||||||
// This is where the init wizard gate lives.
|
// This is where the init wizard gate lives.
|
||||||
const afterSmartEntry = smartEntryIdx >= 0 ? guidedFlowSrc.slice(smartEntryIdx, smartEntryIdx + 3000) : "";
|
const afterSmartEntry = smartEntryIdx >= 0 ? guidedFlowSrc.slice(smartEntryIdx, smartEntryIdx + 3000) : "";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
const AUDIT_ENV_KEY = "GSD_UOK_AUDIT_UNIFIED";
|
const AUDIT_ENV_KEY = "GSD_UOK_AUDIT_ENVELOPE";
|
||||||
|
const LEGACY_AUDIT_ENV_KEY = "GSD_UOK_AUDIT_UNIFIED";
|
||||||
|
|
||||||
export function setUnifiedAuditEnabled(enabled: boolean): void {
|
export function setAuditEnvelopeEnabled(enabled: boolean): void {
|
||||||
process.env[AUDIT_ENV_KEY] = enabled ? "1" : "0";
|
process.env[AUDIT_ENV_KEY] = enabled ? "1" : "0";
|
||||||
|
process.env[LEGACY_AUDIT_ENV_KEY] = enabled ? "1" : "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUnifiedAuditEnabled(): boolean {
|
export function isAuditEnvelopeEnabled(): boolean {
|
||||||
return process.env[AUDIT_ENV_KEY] === "1";
|
return process.env[AUDIT_ENV_KEY] === "1" || process.env[LEGACY_AUDIT_ENV_KEY] === "1";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ export interface UokFlags {
|
||||||
gitops: boolean;
|
gitops: boolean;
|
||||||
gitopsTurnAction: "commit" | "snapshot" | "status-only";
|
gitopsTurnAction: "commit" | "snapshot" | "status-only";
|
||||||
gitopsTurnPush: boolean;
|
gitopsTurnPush: boolean;
|
||||||
auditUnified: boolean;
|
auditEnvelope: boolean;
|
||||||
planV2: boolean;
|
planningFlow: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function envForcesLegacyFallback(): boolean {
|
function envForcesLegacyFallback(): boolean {
|
||||||
|
|
@ -34,8 +34,8 @@ export function resolveUokFlags(prefs: GSDPreferences | undefined): UokFlags {
|
||||||
gitops: uok?.gitops?.enabled === true,
|
gitops: uok?.gitops?.enabled === true,
|
||||||
gitopsTurnAction: uok?.gitops?.turn_action ?? "status-only",
|
gitopsTurnAction: uok?.gitops?.turn_action ?? "status-only",
|
||||||
gitopsTurnPush: uok?.gitops?.turn_push === true,
|
gitopsTurnPush: uok?.gitops?.turn_push === true,
|
||||||
auditUnified: uok?.audit_unified?.enabled === true,
|
auditEnvelope: uok?.audit_envelope?.enabled === true || uok?.audit_unified?.enabled === true,
|
||||||
planV2: uok?.plan_v2?.enabled === true,
|
planningFlow: uok?.planning_flow?.enabled === true || uok?.plan_v2?.enabled === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type { AutoSession } from "../auto/session.js";
|
||||||
import type { LoopDeps } from "../auto/loop-deps.js";
|
import type { LoopDeps } from "../auto/loop-deps.js";
|
||||||
import { gsdRoot } from "../paths.js";
|
import { gsdRoot } from "../paths.js";
|
||||||
import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
|
import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
|
||||||
import { setUnifiedAuditEnabled } from "./audit-toggle.js";
|
import { setAuditEnvelopeEnabled } from "./audit-toggle.js";
|
||||||
import { resolveUokFlags } from "./flags.js";
|
import { resolveUokFlags } from "./flags.js";
|
||||||
import { createTurnObserver } from "./loop-adapter.js";
|
import { createTurnObserver } from "./loop-adapter.js";
|
||||||
|
|
||||||
|
|
@ -45,7 +45,7 @@ export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise<
|
||||||
const { ctx, pi, s, deps, runLegacyLoop } = args;
|
const { ctx, pi, s, deps, runLegacyLoop } = args;
|
||||||
const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
|
const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
|
||||||
const flags = resolveUokFlags(prefs);
|
const flags = resolveUokFlags(prefs);
|
||||||
setUnifiedAuditEnabled(flags.auditUnified);
|
setAuditEnvelopeEnabled(flags.auditEnvelope);
|
||||||
|
|
||||||
writeParityEvent(s.basePath, {
|
writeParityEvent(s.basePath, {
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
|
|
@ -54,7 +54,7 @@ export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise<
|
||||||
phase: "enter",
|
phase: "enter",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (flags.auditUnified) {
|
if (flags.auditEnvelope) {
|
||||||
emitUokAuditEvent(
|
emitUokAuditEvent(
|
||||||
s.basePath,
|
s.basePath,
|
||||||
buildAuditEnvelope({
|
buildAuditEnvelope({
|
||||||
|
|
@ -76,7 +76,7 @@ export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise<
|
||||||
basePath: s.basePath,
|
basePath: s.basePath,
|
||||||
gitAction: flags.gitopsTurnAction,
|
gitAction: flags.gitopsTurnAction,
|
||||||
gitPush: flags.gitopsTurnPush,
|
gitPush: flags.gitopsTurnPush,
|
||||||
enableAudit: flags.auditUnified,
|
enableAudit: flags.auditEnvelope,
|
||||||
enableGitops: flags.gitops,
|
enableGitops: flags.gitops,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { join } from "node:path";
|
||||||
|
|
||||||
import { appendNotification } from "./notification-store.js";
|
import { appendNotification } from "./notification-store.js";
|
||||||
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
|
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
|
||||||
import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js";
|
import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js";
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -277,7 +277,7 @@ function _push(
|
||||||
_buffer.shift();
|
_buffer.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_auditBasePath && isUnifiedAuditEnabled()) {
|
if (_auditBasePath && isAuditEnvelopeEnabled()) {
|
||||||
try {
|
try {
|
||||||
emitUokAuditEvent(
|
emitUokAuditEvent(
|
||||||
_auditBasePath,
|
_auditBasePath,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export function replaySliceComplete(milestoneId: string, sliceId: string, ts: st
|
||||||
const incompleteTasks = tasks.filter(t => !isClosedStatus(t.status));
|
const incompleteTasks = tasks.filter(t => !isClosedStatus(t.status));
|
||||||
if (incompleteTasks.length > 0) {
|
if (incompleteTasks.length > 0) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`[gsd] reconcile: skipping complete_slice replay for ${sliceId} — ` +
|
`[forge] reconcile: skipping complete_slice replay for ${sliceId} — ` +
|
||||||
`${incompleteTasks.length} task(s) still pending\n`,
|
`${incompleteTasks.length} task(s) still pending\n`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -210,13 +210,13 @@ export function stopWebMode(deps: Pick<WebModeDeps, 'pidFilePath' | 'readPidFile
|
||||||
for (const [cwd, entry] of entries) {
|
for (const [cwd, entry] of entries) {
|
||||||
const result = killPid(entry.pid)
|
const result = killPid(entry.pid)
|
||||||
if (result === 'killed') {
|
if (result === 'killed') {
|
||||||
stderr.write(`[gsd] Stopped web server for ${cwd} (pid=${entry.pid})\n`)
|
stderr.write(`[forge] Stopped web server for ${cwd} (pid=${entry.pid})\n`)
|
||||||
stopped++
|
stopped++
|
||||||
} else if (result === 'already-dead') {
|
} else if (result === 'already-dead') {
|
||||||
stderr.write(`[gsd] Web server for ${cwd} was already stopped (pid=${entry.pid})\n`)
|
stderr.write(`[forge] Web server for ${cwd} was already stopped (pid=${entry.pid})\n`)
|
||||||
stopped++
|
stopped++
|
||||||
} else {
|
} else {
|
||||||
stderr.write(`[gsd] Failed to stop web server for ${cwd}: ${result.error}\n`)
|
stderr.write(`[forge] Failed to stop web server for ${cwd}: ${result.error}\n`)
|
||||||
}
|
}
|
||||||
unregisterInstance(cwd)
|
unregisterInstance(cwd)
|
||||||
}
|
}
|
||||||
|
|
@ -224,7 +224,7 @@ export function stopWebMode(deps: Pick<WebModeDeps, 'pidFilePath' | 'readPidFile
|
||||||
const deletePid = deps.deletePidFile ?? deletePidFile
|
const deletePid = deps.deletePidFile ?? deletePidFile
|
||||||
const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath
|
const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath
|
||||||
deletePid(pidFilePath)
|
deletePid(pidFilePath)
|
||||||
stderr.write(`[gsd] Stopped ${stopped} instance${stopped === 1 ? '' : 's'}.\n`)
|
stderr.write(`[forge] Stopped ${stopped} instance${stopped === 1 ? '' : 's'}.\n`)
|
||||||
return { ok: true, stoppedCount: stopped }
|
return { ok: true, stoppedCount: stopped }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,19 +234,19 @@ export function stopWebMode(deps: Pick<WebModeDeps, 'pidFilePath' | 'readPidFile
|
||||||
const registry = readInstanceRegistry()
|
const registry = readInstanceRegistry()
|
||||||
const entry = registry[resolvedCwd]
|
const entry = registry[resolvedCwd]
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
stderr.write(`[gsd] No web server running for ${resolvedCwd}\n`)
|
stderr.write(`[forge] No web server running for ${resolvedCwd}\n`)
|
||||||
return { ok: false, reason: 'not-found' }
|
return { ok: false, reason: 'not-found' }
|
||||||
}
|
}
|
||||||
const result = killPid(entry.pid)
|
const result = killPid(entry.pid)
|
||||||
unregisterInstance(resolvedCwd)
|
unregisterInstance(resolvedCwd)
|
||||||
if (result === 'killed') {
|
if (result === 'killed') {
|
||||||
stderr.write(`[gsd] Stopped web server for ${resolvedCwd} (pid=${entry.pid})\n`)
|
stderr.write(`[forge] Stopped web server for ${resolvedCwd} (pid=${entry.pid})\n`)
|
||||||
return { ok: true, stoppedCount: 1 }
|
return { ok: true, stoppedCount: 1 }
|
||||||
} else if (result === 'already-dead') {
|
} else if (result === 'already-dead') {
|
||||||
stderr.write(`[gsd] Web server for ${resolvedCwd} was already stopped — cleared stale entry.\n`)
|
stderr.write(`[forge] Web server for ${resolvedCwd} was already stopped — cleared stale entry.\n`)
|
||||||
return { ok: true, stoppedCount: 1 }
|
return { ok: true, stoppedCount: 1 }
|
||||||
} else {
|
} else {
|
||||||
stderr.write(`[gsd] Failed to stop web server for ${resolvedCwd}: ${result.error}\n`)
|
stderr.write(`[forge] Failed to stop web server for ${resolvedCwd}: ${result.error}\n`)
|
||||||
return { ok: false, reason: result.error }
|
return { ok: false, reason: result.error }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -263,22 +263,22 @@ function stopLegacyPidFile(deps: Pick<WebModeDeps, 'pidFilePath' | 'readPidFile'
|
||||||
|
|
||||||
const pid = readPid(pidFilePath)
|
const pid = readPid(pidFilePath)
|
||||||
if (pid === null) {
|
if (pid === null) {
|
||||||
stderr.write(`[gsd] Web server is not running (no PID file found)\n`)
|
stderr.write(`[forge] Web server is not running (no PID file found)\n`)
|
||||||
return { ok: false, reason: 'no-pid-file' }
|
return { ok: false, reason: 'no-pid-file' }
|
||||||
}
|
}
|
||||||
|
|
||||||
stderr.write(`[gsd] Stopping web server (pid=${pid})…\n`)
|
stderr.write(`[forge] Stopping web server (pid=${pid})…\n`)
|
||||||
|
|
||||||
const result = killPid(pid)
|
const result = killPid(pid)
|
||||||
deletePid(pidFilePath)
|
deletePid(pidFilePath)
|
||||||
if (result === 'killed') {
|
if (result === 'killed') {
|
||||||
stderr.write(`[gsd] Web server stopped.\n`)
|
stderr.write(`[forge] Web server stopped.\n`)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
} else if (result === 'already-dead') {
|
} else if (result === 'already-dead') {
|
||||||
stderr.write(`[gsd] Web server was already stopped — cleared stale PID file.\n`)
|
stderr.write(`[forge] Web server was already stopped — cleared stale PID file.\n`)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
} else {
|
} else {
|
||||||
stderr.write(`[gsd] Failed to stop web server: ${result.error}\n`)
|
stderr.write(`[forge] Failed to stop web server: ${result.error}\n`)
|
||||||
return { ok: false, reason: result.error }
|
return { ok: false, reason: result.error }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -359,10 +359,10 @@ function needsWindowsShell(command: string, platform: NodeJS.Platform): boolean
|
||||||
|
|
||||||
function formatLaunchStatus(status: WebModeLaunchStatus): string {
|
function formatLaunchStatus(status: WebModeLaunchStatus): string {
|
||||||
if (status.ok) {
|
if (status.ok) {
|
||||||
return `[gsd] Web mode startup: status=started cwd=${status.cwd} port=${status.port} host=${status.hostPath} kind=${status.hostKind} url=${status.url}\n`
|
return `[forge] Web mode startup: status=started cwd=${status.cwd} port=${status.port} host=${status.hostPath} kind=${status.hostKind} url=${status.url}\n`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `[gsd] Web mode startup: status=failed cwd=${status.cwd} port=${status.port ?? 'n/a'} host=${status.hostPath ?? 'unresolved'} kind=${status.hostKind} reason=${status.failureReason}\n`
|
return `[forge] Web mode startup: status=failed cwd=${status.cwd} port=${status.port ?? 'n/a'} host=${status.hostPath ?? 'unresolved'} kind=${status.hostKind} reason=${status.failureReason}\n`
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitLaunchStatus(stderr: WritableLike, status: WebModeLaunchStatus): void {
|
function emitLaunchStatus(stderr: WritableLike, status: WebModeLaunchStatus): void {
|
||||||
|
|
@ -475,7 +475,7 @@ async function waitForBootReady(url: string, timeoutMs = 180_000, stderr?: Writa
|
||||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
if (!hostUp) {
|
if (!hostUp) {
|
||||||
hostUp = true
|
hostUp = true
|
||||||
stderr?.write(`[gsd] Web host ready.\n`)
|
stderr?.write(`[forge] Web host ready.\n`)
|
||||||
}
|
}
|
||||||
consecutive5xx = 0
|
consecutive5xx = 0
|
||||||
// Host responded successfully — it's ready for the browser
|
// Host responded successfully — it's ready for the browser
|
||||||
|
|
@ -508,9 +508,9 @@ async function waitForBootReady(url: string, timeoutMs = 180_000, stderr?: Writa
|
||||||
if (now - lastTickAt >= TICKER_INTERVAL_MS) {
|
if (now - lastTickAt >= TICKER_INTERVAL_MS) {
|
||||||
lastTickAt = now
|
lastTickAt = now
|
||||||
if (hostUp) {
|
if (hostUp) {
|
||||||
stderr?.write(`[gsd] Still waiting… (${elapsed()})\n`)
|
stderr?.write(`[forge] Still waiting… (${elapsed()})\n`)
|
||||||
} else {
|
} else {
|
||||||
stderr?.write(`[gsd] Waiting for web host… (${elapsed()})\n`)
|
stderr?.write(`[forge] Waiting for web host… (${elapsed()})\n`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,14 +532,14 @@ function cleanupStaleInstance(cwd: string, stderr: WritableLike, registryPath?:
|
||||||
const stale = registry[key]
|
const stale = registry[key]
|
||||||
if (!stale) return
|
if (!stale) return
|
||||||
|
|
||||||
stderr.write(`[gsd] Cleaning up stale web server for ${key} (pid=${stale.pid}, port=${stale.port})…\n`)
|
stderr.write(`[forge] Cleaning up stale web server for ${key} (pid=${stale.pid}, port=${stale.port})…\n`)
|
||||||
const result = killPid(stale.pid)
|
const result = killPid(stale.pid)
|
||||||
if (result === 'killed') {
|
if (result === 'killed') {
|
||||||
stderr.write(`[gsd] Killed stale web server (pid=${stale.pid}).\n`)
|
stderr.write(`[forge] Killed stale web server (pid=${stale.pid}).\n`)
|
||||||
} else if (result === 'already-dead') {
|
} else if (result === 'already-dead') {
|
||||||
stderr.write(`[gsd] Stale web server was already stopped (pid=${stale.pid}) — clearing entry.\n`)
|
stderr.write(`[forge] Stale web server was already stopped (pid=${stale.pid}) — clearing entry.\n`)
|
||||||
} else {
|
} else {
|
||||||
stderr.write(`[gsd] Could not kill stale web server (pid=${stale.pid}): ${result.error}\n`)
|
stderr.write(`[forge] Could not kill stale web server (pid=${stale.pid}): ${result.error}\n`)
|
||||||
}
|
}
|
||||||
unregisterInstance(cwd, registryPath)
|
unregisterInstance(cwd, registryPath)
|
||||||
}
|
}
|
||||||
|
|
@ -574,7 +574,7 @@ export async function launchWebMode(
|
||||||
return failure
|
return failure
|
||||||
}
|
}
|
||||||
|
|
||||||
stderr.write(`[gsd] Starting web mode…\n`)
|
stderr.write(`[forge] Starting web mode…\n`)
|
||||||
|
|
||||||
// Kill any stale server instance for this project before reserving a port.
|
// Kill any stale server instance for this project before reserving a port.
|
||||||
// This prevents EADDRINUSE when the previous `gsd --web` was terminated
|
// This prevents EADDRINUSE when the previous `gsd --web` was terminated
|
||||||
|
|
@ -600,7 +600,7 @@ export async function launchWebMode(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
stderr.write(`[gsd] Initialising resources…\n`)
|
stderr.write(`[forge] Initialising resources…\n`)
|
||||||
const bootstrap = deps.initResources ? { initResources: deps.initResources } : await loadResourceBootstrap()
|
const bootstrap = deps.initResources ? { initResources: deps.initResources } : await loadResourceBootstrap()
|
||||||
bootstrap.initResources(options.agentDir)
|
bootstrap.initResources(options.agentDir)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -629,7 +629,7 @@ export async function launchWebMode(
|
||||||
deps.execPath ?? process.execPath,
|
deps.execPath ?? process.execPath,
|
||||||
)
|
)
|
||||||
|
|
||||||
stderr.write(`[gsd] Launching web host on port ${port}…\n`)
|
stderr.write(`[forge] Launching web host on port ${port}…\n`)
|
||||||
|
|
||||||
const spawnResult = await spawnDetachedProcess(
|
const spawnResult = await spawnDetachedProcess(
|
||||||
deps.spawn ?? ((command, args, spawnOptions) => spawn(command, args, spawnOptions)),
|
deps.spawn ?? ((command, args, spawnOptions) => spawn(command, args, spawnOptions)),
|
||||||
|
|
@ -697,7 +697,7 @@ export async function launchWebMode(
|
||||||
try {
|
try {
|
||||||
;(deps.openBrowser ?? openBrowser)(authenticatedUrl)
|
;(deps.openBrowser ?? openBrowser)(authenticatedUrl)
|
||||||
} catch (browserError) {
|
} catch (browserError) {
|
||||||
stderr.write(`[gsd] Could not open browser: ${browserError instanceof Error ? browserError.message : String(browserError)}\n`)
|
stderr.write(`[forge] Could not open browser: ${browserError instanceof Error ? browserError.message : String(browserError)}\n`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const failure: WebModeLaunchFailure = {
|
const failure: WebModeLaunchFailure = {
|
||||||
|
|
@ -730,7 +730,7 @@ export async function launchWebMode(
|
||||||
hostPath: resolution.entryPath,
|
hostPath: resolution.entryPath,
|
||||||
hostRoot: resolution.hostRoot,
|
hostRoot: resolution.hostRoot,
|
||||||
}
|
}
|
||||||
stderr.write(`[gsd] Ready → ${authenticatedUrl}\n`)
|
stderr.write(`[forge] Ready → ${authenticatedUrl}\n`)
|
||||||
emitLaunchStatus(stderr, success)
|
emitLaunchStatus(stderr, success)
|
||||||
return success
|
return success
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -302,10 +302,10 @@ async function handleStatusBanner(basePath: string): Promise<void> {
|
||||||
|
|
||||||
const names = withChanges.map(w => chalk.cyan(w.name)).join(', ')
|
const names = withChanges.map(w => chalk.cyan(w.name)).join(', ')
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
chalk.dim('[gsd] ') +
|
chalk.dim('[forge] ') +
|
||||||
chalk.yellow(`${withChanges.length} worktree${withChanges.length === 1 ? '' : 's'} with unmerged changes: `) +
|
chalk.yellow(`${withChanges.length} worktree${withChanges.length === 1 ? '' : 's'} with unmerged changes: `) +
|
||||||
names + '\n' +
|
names + '\n' +
|
||||||
chalk.dim('[gsd] ') +
|
chalk.dim('[forge] ') +
|
||||||
chalk.dim('Resume: gsd -w <name> | Merge: gsd worktree merge <name> | List: gsd worktree list\n\n'),
|
chalk.dim('Resume: gsd -w <name> | Merge: gsd worktree merge <name> | List: gsd worktree list\n\n'),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -378,7 +378,7 @@ async function createAndEnter(ext: ExtensionModules, basePath: string, name: str
|
||||||
|
|
||||||
const hookError = ext.runWorktreePostCreateHook(basePath, info.path)
|
const hookError = ext.runWorktreePostCreateHook(basePath, info.path)
|
||||||
if (hookError) {
|
if (hookError) {
|
||||||
process.stderr.write(chalk.yellow(`[gsd] ${hookError}\n`))
|
process.stderr.write(chalk.yellow(`[forge] ${hookError}\n`))
|
||||||
}
|
}
|
||||||
|
|
||||||
process.chdir(info.path)
|
process.chdir(info.path)
|
||||||
|
|
@ -389,7 +389,7 @@ async function createAndEnter(ext: ExtensionModules, basePath: string, name: str
|
||||||
process.stderr.write(chalk.dim(` branch ${info.branch}\n\n`))
|
process.stderr.write(chalk.dim(` branch ${info.branch}\n\n`))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
process.stderr.write(chalk.red(`[gsd] Failed to create worktree: ${msg}\n`))
|
process.stderr.write(chalk.red(`[forge] Failed to create worktree: ${msg}\n`))
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
78
web/components/sf/activity-view.tsx
Normal file
78
web/components/sf/activity-view.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CheckCircle2, Play, Clock, Terminal, AlertCircle } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useGSDWorkspaceState, type TerminalLineType } from "@/lib/sf-workspace-store"
|
||||||
|
|
||||||
|
function EventIcon({ type }: { type: TerminalLineType }) {
|
||||||
|
const baseClass = "h-4 w-4"
|
||||||
|
switch (type) {
|
||||||
|
case "system":
|
||||||
|
return <Clock className={cn(baseClass, "text-info")} />
|
||||||
|
case "success":
|
||||||
|
return <CheckCircle2 className={cn(baseClass, "text-success")} />
|
||||||
|
case "error":
|
||||||
|
return <AlertCircle className={cn(baseClass, "text-destructive")} />
|
||||||
|
case "output":
|
||||||
|
return <Terminal className={cn(baseClass, "text-foreground")} />
|
||||||
|
case "input":
|
||||||
|
return <Play className={cn(baseClass, "text-warning")} />
|
||||||
|
default:
|
||||||
|
return <Clock className={cn(baseClass, "text-muted-foreground")} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityView() {
|
||||||
|
const workspace = useGSDWorkspaceState()
|
||||||
|
const terminalLines = workspace.terminalLines ?? []
|
||||||
|
|
||||||
|
// Show most recent events first
|
||||||
|
const reversedLines = [...terminalLines].reverse()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
<div className="border-b border-border px-6 py-3">
|
||||||
|
<h1 className="text-lg font-semibold">Activity Log</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Execution history and git operations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{reversedLines.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No activity yet. Events will appear here once the workspace is active.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative px-6 py-4">
|
||||||
|
{/* Timeline line */}
|
||||||
|
<div className="absolute left-10 top-6 bottom-6 w-px bg-border" />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{reversedLines.map((line) => (
|
||||||
|
<div key={line.id} className="relative flex gap-4">
|
||||||
|
{/* Timeline dot */}
|
||||||
|
<div className="relative z-10 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border border-border bg-card">
|
||||||
|
<EventIcon type={line.type} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 pt-0.5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{line.content}</p>
|
||||||
|
</div>
|
||||||
|
<span className="flex-shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
|
{line.timestamp}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
605
web/components/sf/app-shell.tsx
Normal file
605
web/components/sf/app-shell.tsx
Normal file
|
|
@ -0,0 +1,605 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import Image from "next/image"
|
||||||
|
import { useState, useEffect, useCallback, useRef, useSyncExternalStore } from "react"
|
||||||
|
import { Menu, X } from "lucide-react"
|
||||||
|
import { Sidebar, MilestoneExplorer, CollapsedMilestoneSidebar } from "@/components/sf/sidebar"
|
||||||
|
import { ShellTerminal } from "@/components/sf/shell-terminal"
|
||||||
|
import { Dashboard } from "@/components/sf/dashboard"
|
||||||
|
import { Roadmap } from "@/components/sf/roadmap"
|
||||||
|
import { FilesView } from "@/components/sf/files-view"
|
||||||
|
import { ActivityView } from "@/components/sf/activity-view"
|
||||||
|
import { VisualizerView } from "@/components/sf/visualizer-view"
|
||||||
|
import { StatusBar } from "@/components/sf/status-bar"
|
||||||
|
import { DualTerminal } from "@/components/sf/dual-terminal"
|
||||||
|
import { FocusedPanel } from "@/components/sf/focused-panel"
|
||||||
|
import { OnboardingGate } from "@/components/sf/onboarding-gate"
|
||||||
|
import { CommandSurface } from "@/components/sf/command-surface"
|
||||||
|
import { DevOverridesProvider } from "@/lib/dev-overrides"
|
||||||
|
import { ProjectStoreManagerProvider, useProjectStoreManager } from "@/lib/project-store-manager"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
GSDWorkspaceProvider,
|
||||||
|
getCurrentScopeLabel,
|
||||||
|
getProjectDisplayName,
|
||||||
|
getStatusPresentation,
|
||||||
|
getVisibleWorkspaceError,
|
||||||
|
useGSDWorkspaceState,
|
||||||
|
useGSDWorkspaceActions,
|
||||||
|
} from "@/lib/sf-workspace-store"
|
||||||
|
import { ChatMode } from "@/components/sf/chat-mode"
|
||||||
|
import { ScopeBadge } from "@/components/sf/scope-badge"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { ProjectsPanel, ProjectSelectionGate } from "@/components/sf/projects-view"
|
||||||
|
import { UpdateBanner } from "@/components/sf/update-banner"
|
||||||
|
import { getAuthToken } from "@/lib/auth"
|
||||||
|
|
||||||
|
const KNOWN_VIEWS = new Set(["dashboard", "power", "chat", "roadmap", "files", "activity", "visualize"])
|
||||||
|
|
||||||
|
function viewStorageKey(projectCwd: string): string {
|
||||||
|
return `gsd-active-view:${projectCwd}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceChrome() {
|
||||||
|
const [activeView, setActiveView] = useState("dashboard")
|
||||||
|
const [isTerminalExpanded, setIsTerminalExpanded] = useState(false)
|
||||||
|
const [terminalHeight, setTerminalHeight] = useState(300)
|
||||||
|
const [terminalDragActive, setTerminalDragActive] = useState(false)
|
||||||
|
const isDraggingTerminal = useRef(false)
|
||||||
|
const didDragTerminal = useRef(false)
|
||||||
|
const dragStartY = useRef(0)
|
||||||
|
const dragStartHeight = useRef(0)
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(256)
|
||||||
|
const isDraggingSidebar = useRef(false)
|
||||||
|
const dragStartX = useRef(0)
|
||||||
|
const dragStartWidth = useRef(0)
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
const [viewRestored, setViewRestored] = useState(false)
|
||||||
|
const [projectsPanelOpen, setProjectsPanelOpen] = useState(false)
|
||||||
|
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||||
|
const [mobileMilestoneOpen, setMobileMilestoneOpen] = useState(false)
|
||||||
|
const workspace = useGSDWorkspaceState()
|
||||||
|
const { refreshBoot } = useGSDWorkspaceActions()
|
||||||
|
|
||||||
|
const status = getStatusPresentation(workspace)
|
||||||
|
const projectPath = workspace.boot?.project.cwd
|
||||||
|
const projectLabel = getProjectDisplayName(projectPath)
|
||||||
|
const titleOverride = workspace.titleOverride?.trim() || null
|
||||||
|
const scopeLabel = getCurrentScopeLabel(workspace.boot?.workspace)
|
||||||
|
const visibleError = getVisibleWorkspaceError(workspace)
|
||||||
|
|
||||||
|
// Restore persisted view once boot provides projectCwd
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewRestored || !projectPath) return
|
||||||
|
const restoreTimer = window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(viewStorageKey(projectPath))
|
||||||
|
if (stored && KNOWN_VIEWS.has(stored)) {
|
||||||
|
setActiveView(stored)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sessionStorage may be unavailable (e.g. SSR, iframe sandbox)
|
||||||
|
}
|
||||||
|
setViewRestored(true)
|
||||||
|
}, 0)
|
||||||
|
return () => window.clearTimeout(restoreTimer)
|
||||||
|
}, [projectPath, viewRestored])
|
||||||
|
|
||||||
|
// Reset viewRestored when projectPath changes so the restore effect can
|
||||||
|
// fire for the newly-selected project (fixes #2711: tab reset on switch).
|
||||||
|
const prevProjectPath = useRef(projectPath)
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevProjectPath.current !== projectPath) {
|
||||||
|
prevProjectPath.current = projectPath
|
||||||
|
setViewRestored(false)
|
||||||
|
}
|
||||||
|
}, [projectPath])
|
||||||
|
|
||||||
|
// Persist view changes to sessionStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectPath) return
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(viewStorageKey(projectPath), activeView)
|
||||||
|
} catch {
|
||||||
|
// sessionStorage may be unavailable
|
||||||
|
}
|
||||||
|
}, [activeView, projectPath])
|
||||||
|
|
||||||
|
// Restore sidebar collapsed state from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const restoreTimer = window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("gsd-sidebar-collapsed")
|
||||||
|
if (stored === "true") setSidebarCollapsed(true)
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
return () => window.clearTimeout(restoreTimer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Persist sidebar collapsed state
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("gsd-sidebar-collapsed", String(sidebarCollapsed))
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable
|
||||||
|
}
|
||||||
|
}, [sidebarCollapsed])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
const base = projectLabel ? `GSD - ${projectLabel}` : "GSD"
|
||||||
|
document.title = titleOverride ? `${titleOverride} · ${base}` : base
|
||||||
|
}, [titleOverride, projectLabel])
|
||||||
|
|
||||||
|
// Close mobile nav on view change
|
||||||
|
const handleViewChange = useCallback((view: string) => {
|
||||||
|
setActiveView(view)
|
||||||
|
setMobileNavOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Listen for cross-component file navigation events (e.g. sidebar task clicks)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
setActiveView("files")
|
||||||
|
}
|
||||||
|
window.addEventListener("gsd:open-file", handler)
|
||||||
|
return () => window.removeEventListener("gsd:open-file", handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Listen for cross-component view navigation events (e.g. /gsd visualize dispatch)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: CustomEvent<{ view: string }>) => {
|
||||||
|
if (KNOWN_VIEWS.has(e.detail.view)) {
|
||||||
|
handleViewChange(e.detail.view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("gsd:navigate-view", handler as EventListener)
|
||||||
|
return () => window.removeEventListener("gsd:navigate-view", handler as EventListener)
|
||||||
|
}, [handleViewChange])
|
||||||
|
|
||||||
|
// Listen for projects panel toggle (sidebar icon, or programmatic)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setProjectsPanelOpen(true)
|
||||||
|
window.addEventListener("gsd:open-projects", handler)
|
||||||
|
return () => window.removeEventListener("gsd:open-projects", handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Terminal + sidebar panel drag-to-resize
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (isDraggingTerminal.current) {
|
||||||
|
didDragTerminal.current = true
|
||||||
|
const delta = dragStartY.current - e.clientY
|
||||||
|
const newHeight = Math.max(150, Math.min(600, dragStartHeight.current + delta))
|
||||||
|
setTerminalHeight(newHeight)
|
||||||
|
}
|
||||||
|
if (isDraggingSidebar.current) {
|
||||||
|
const delta = dragStartX.current - e.clientX
|
||||||
|
const newWidth = Math.max(180, Math.min(480, dragStartWidth.current + delta))
|
||||||
|
setSidebarWidth(newWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
isDraggingTerminal.current = false
|
||||||
|
isDraggingSidebar.current = false
|
||||||
|
setTerminalDragActive(false)
|
||||||
|
document.body.style.cursor = ""
|
||||||
|
document.body.style.userSelect = ""
|
||||||
|
}
|
||||||
|
document.addEventListener("mousemove", handleMouseMove)
|
||||||
|
document.addEventListener("mouseup", handleMouseUp)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove)
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTerminalDragStart = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
isDraggingTerminal.current = true
|
||||||
|
setTerminalDragActive(true)
|
||||||
|
dragStartY.current = e.clientY
|
||||||
|
dragStartHeight.current = terminalHeight
|
||||||
|
document.body.style.cursor = "row-resize"
|
||||||
|
document.body.style.userSelect = "none"
|
||||||
|
},
|
||||||
|
[terminalHeight],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSidebarDragStart = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
isDraggingSidebar.current = true
|
||||||
|
dragStartX.current = e.clientX
|
||||||
|
dragStartWidth.current = sidebarWidth
|
||||||
|
document.body.style.cursor = "col-resize"
|
||||||
|
document.body.style.userSelect = "none"
|
||||||
|
},
|
||||||
|
[sidebarWidth],
|
||||||
|
)
|
||||||
|
|
||||||
|
const retryDisabled = !!workspace.commandInFlight || workspace.onboardingRequestState !== "idle"
|
||||||
|
const isConnecting = workspace.bootStatus === "idle" || workspace.bootStatus === "loading"
|
||||||
|
|
||||||
|
// Persistent loading toast — dismissed the moment boot completes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConnecting) return
|
||||||
|
const id = toast.loading("Connecting to workspace…", {
|
||||||
|
description: "Establishing the live bridge session",
|
||||||
|
duration: Infinity,
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
toast.dismiss(id)
|
||||||
|
}
|
||||||
|
}, [isConnecting])
|
||||||
|
|
||||||
|
// Detect project welcome state — hide chrome for v1-legacy, brownfield, blank projects
|
||||||
|
const detection = workspace.boot?.projectDetection
|
||||||
|
const isWelcomeState =
|
||||||
|
!isConnecting &&
|
||||||
|
activeView === "dashboard" &&
|
||||||
|
detection != null &&
|
||||||
|
detection.kind !== "active-gsd" &&
|
||||||
|
detection.kind !== "empty-gsd"
|
||||||
|
|
||||||
|
// --- Unauthenticated gate ---
|
||||||
|
// Render a clear recovery screen before any workspace chrome is mounted so
|
||||||
|
// users who open a manually-typed URL (no #token= fragment) get actionable
|
||||||
|
// guidance instead of a cascade of 401 errors.
|
||||||
|
if (workspace.bootStatus === "unauthenticated") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh flex-col items-center justify-center gap-6 bg-background p-8 text-center">
|
||||||
|
<Image
|
||||||
|
src="/logo-black.svg"
|
||||||
|
alt="GSD"
|
||||||
|
width={57}
|
||||||
|
height={16}
|
||||||
|
className="shrink-0 h-4 w-auto dark:hidden"
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src="/logo-white.svg"
|
||||||
|
alt="GSD"
|
||||||
|
width={57}
|
||||||
|
height={16}
|
||||||
|
className="shrink-0 h-4 w-auto hidden dark:block"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Authentication Required</h1>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">
|
||||||
|
This workspace requires an auth token. Copy the full URL from your terminal
|
||||||
|
(including the{" "}
|
||||||
|
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">#token=…</code>{" "}
|
||||||
|
part) or restart with{" "}
|
||||||
|
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">gsd --web</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
||||||
|
<header className="flex h-12 flex-shrink-0 items-center justify-between border-b border-border bg-card px-2 md:px-4">
|
||||||
|
<div className="flex items-center gap-2 md:gap-3 min-w-0">
|
||||||
|
{/* Mobile hamburger menu */}
|
||||||
|
<button
|
||||||
|
className="flex md:hidden h-10 w-10 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||||
|
onClick={() => setMobileNavOpen(!mobileNavOpen)}
|
||||||
|
aria-label={mobileNavOpen ? "Close navigation" : "Open navigation"}
|
||||||
|
data-testid="mobile-nav-toggle"
|
||||||
|
>
|
||||||
|
{mobileNavOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Image
|
||||||
|
src="/logo-black.svg"
|
||||||
|
alt="GSD"
|
||||||
|
width={57}
|
||||||
|
height={16}
|
||||||
|
className="shrink-0 h-4 w-auto dark:hidden"
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src="/logo-white.svg"
|
||||||
|
alt="GSD"
|
||||||
|
width={57}
|
||||||
|
height={16}
|
||||||
|
className="shrink-0 h-4 w-auto hidden dark:block"
|
||||||
|
/>
|
||||||
|
<Badge variant="outline" className="hidden sm:inline-flex text-[10px] rounded-full border-foreground/15 bg-accent/40 text-muted-foreground font-normal">
|
||||||
|
beta
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="hidden sm:inline text-2xl font-thin text-muted-foreground leading-none select-none">/</span>
|
||||||
|
<span className="hidden sm:inline text-sm text-muted-foreground truncate" data-testid="workspace-project-cwd" title={projectPath ?? undefined}>
|
||||||
|
{isConnecting ? (
|
||||||
|
<Skeleton className="inline-block h-4 w-28 align-middle" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{projectLabel}
|
||||||
|
{titleOverride && (
|
||||||
|
<span
|
||||||
|
className="ml-2 inline-flex items-center rounded-full border border-foreground/15 bg-accent/60 px-2 py-0.5 text-[10px] font-medium text-foreground"
|
||||||
|
data-testid="workspace-title-override"
|
||||||
|
title={titleOverride}
|
||||||
|
>
|
||||||
|
{titleOverride}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 md:gap-3">
|
||||||
|
{/* Hidden status marker for test instrumentation */}
|
||||||
|
<span className="sr-only" data-testid="workspace-connection-status">{status.label}</span>
|
||||||
|
<span
|
||||||
|
className="hidden sm:inline text-xs text-muted-foreground"
|
||||||
|
data-testid="workspace-scope-label"
|
||||||
|
>
|
||||||
|
{isConnecting ? <Skeleton className="inline-block h-3.5 w-40 align-middle" /> : <ScopeBadge label={scopeLabel} size="sm" />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<UpdateBanner />
|
||||||
|
|
||||||
|
{!isConnecting && visibleError && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 border-b border-destructive/20 bg-destructive/10 px-4 py-2 text-xs text-destructive"
|
||||||
|
data-testid="workspace-error-banner"
|
||||||
|
>
|
||||||
|
<span className="flex-1">{visibleError}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => void refreshBoot()}
|
||||||
|
disabled={retryDisabled}
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 rounded border border-destructive/30 bg-background px-2 py-0.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10",
|
||||||
|
retryDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile navigation drawer */}
|
||||||
|
{mobileNavOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/50 md:hidden"
|
||||||
|
onClick={() => setMobileNavOpen(false)}
|
||||||
|
data-testid="mobile-nav-overlay"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 left-0 z-50 w-64 transform bg-sidebar border-r border-border transition-transform duration-200 ease-out md:hidden",
|
||||||
|
mobileNavOpen ? "translate-x-0" : "-translate-x-full",
|
||||||
|
)}
|
||||||
|
data-testid="mobile-nav-drawer"
|
||||||
|
>
|
||||||
|
<Sidebar activeView={activeView} onViewChange={isConnecting ? () => {} : handleViewChange} isConnecting={isConnecting} mobile />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile milestone drawer */}
|
||||||
|
{mobileMilestoneOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/50 md:hidden"
|
||||||
|
onClick={() => setMobileMilestoneOpen(false)}
|
||||||
|
data-testid="mobile-milestone-overlay"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isWelcomeState && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 right-0 z-50 w-72 transform bg-sidebar border-l border-border transition-transform duration-200 ease-out md:hidden",
|
||||||
|
mobileMilestoneOpen ? "translate-x-0" : "translate-x-full",
|
||||||
|
)}
|
||||||
|
data-testid="mobile-milestone-drawer"
|
||||||
|
>
|
||||||
|
<MilestoneExplorer
|
||||||
|
isConnecting={isConnecting}
|
||||||
|
width={288}
|
||||||
|
onCollapse={() => setMobileMilestoneOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Desktop sidebar — hidden on mobile */}
|
||||||
|
<div className="hidden md:flex">
|
||||||
|
<Sidebar activeView={activeView} onViewChange={isConnecting ? () => {} : handleViewChange} isConnecting={isConnecting} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 overflow-hidden transition-all",
|
||||||
|
isTerminalExpanded && "h-1/3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isConnecting ? (
|
||||||
|
<Dashboard />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{activeView === "dashboard" && (
|
||||||
|
<Dashboard
|
||||||
|
onSwitchView={handleViewChange}
|
||||||
|
onExpandTerminal={() => setIsTerminalExpanded(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeView === "power" && <DualTerminal />}
|
||||||
|
{activeView === "roadmap" && <Roadmap />}
|
||||||
|
{activeView === "files" && <FilesView />}
|
||||||
|
{activeView === "activity" && <ActivityView />}
|
||||||
|
{activeView === "visualize" && <VisualizerView />}
|
||||||
|
{activeView === "chat" && <ChatMode />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeView !== "power" && activeView !== "chat" && (
|
||||||
|
<div className="border-t border-border flex flex-col" style={{ flexShrink: 0 }}>
|
||||||
|
{/* Drag handle + toggle header — entire bar is clickable */}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
if (didDragTerminal.current) {
|
||||||
|
didDragTerminal.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isConnecting) setIsTerminalExpanded(!isTerminalExpanded)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isConnecting) setIsTerminalExpanded(!isTerminalExpanded)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-full items-center justify-between bg-card px-3 text-xs select-none transition-colors",
|
||||||
|
isTerminalExpanded && "cursor-row-resize",
|
||||||
|
!isTerminalExpanded && !isConnecting && "cursor-pointer hover:bg-muted/50",
|
||||||
|
isConnecting && "cursor-default",
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (isTerminalExpanded) handleTerminalDragStart(e)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">Terminal</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{isTerminalExpanded ? "▼" : "▲"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Terminal content */}
|
||||||
|
<div
|
||||||
|
className="overflow-hidden"
|
||||||
|
style={{ height: isTerminalExpanded ? terminalHeight : 0, transition: terminalDragActive ? "none" : "height 200ms" }}
|
||||||
|
>
|
||||||
|
<ShellTerminal className="h-full" projectCwd={workspace.boot?.project.cwd} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resizable milestone sidebar — hidden on mobile, hidden during project welcome */}
|
||||||
|
{!isWelcomeState && !sidebarCollapsed && (
|
||||||
|
<div
|
||||||
|
className="relative hidden md:flex h-full items-stretch"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{/* Thin visible border */}
|
||||||
|
<div className="w-px bg-border" />
|
||||||
|
{/* Wide invisible grab area overlapping the border */}
|
||||||
|
<div
|
||||||
|
className="absolute left-[-3px] top-0 bottom-0 w-[7px] cursor-col-resize z-10 hover:bg-muted-foreground/20 transition-colors"
|
||||||
|
onMouseDown={handleSidebarDragStart}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="hidden md:flex">
|
||||||
|
{!isWelcomeState && (sidebarCollapsed ? (
|
||||||
|
<CollapsedMilestoneSidebar onExpand={() => setSidebarCollapsed(false)} />
|
||||||
|
) : (
|
||||||
|
<MilestoneExplorer
|
||||||
|
isConnecting={isConnecting}
|
||||||
|
width={sidebarWidth}
|
||||||
|
onCollapse={() => setSidebarCollapsed(true)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop status bar — hidden on mobile */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<StatusBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile bottom bar — quick access to milestones + status */}
|
||||||
|
{!isWelcomeState && (
|
||||||
|
<div className="flex md:hidden h-12 items-center justify-between border-t border-border bg-card px-3" data-testid="mobile-bottom-bar">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground truncate">
|
||||||
|
<span className="sr-only" data-testid="workspace-connection-status-mobile">{status.label}</span>
|
||||||
|
<span className={cn("h-2 w-2 rounded-full shrink-0", status.tone === "success" ? "bg-success" : status.tone === "warning" ? "bg-warning" : status.tone === "danger" ? "bg-destructive" : "bg-muted-foreground")} />
|
||||||
|
<span className="truncate">{scopeLabel}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMilestoneOpen(!mobileMilestoneOpen)}
|
||||||
|
className="flex h-10 items-center gap-2 rounded-md px-3 text-xs font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||||
|
data-testid="mobile-milestone-toggle"
|
||||||
|
>
|
||||||
|
Milestones
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProjectsPanel open={projectsPanelOpen} onOpenChange={setProjectsPanelOpen} />
|
||||||
|
<CommandSurface />
|
||||||
|
<FocusedPanel />
|
||||||
|
<OnboardingGate />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GSDAppShell() {
|
||||||
|
// Extract the auth token from the URL fragment on first render.
|
||||||
|
// Must happen before any API calls fire.
|
||||||
|
getAuthToken()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectStoreManagerProvider>
|
||||||
|
<ProjectAwareWorkspace />
|
||||||
|
</ProjectStoreManagerProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectAwareWorkspace() {
|
||||||
|
const manager = useProjectStoreManager()
|
||||||
|
const activeProjectCwd = useSyncExternalStore(manager.subscribe, manager.getSnapshot, manager.getSnapshot)
|
||||||
|
const activeStore = activeProjectCwd ? manager.getActiveStore() : null
|
||||||
|
|
||||||
|
// Shut down all projects when the tab actually closes.
|
||||||
|
// IMPORTANT: pagehide fires both on real page unload AND on mobile/Safari
|
||||||
|
// tab switches (bfcache entry). When event.persisted is true the page is
|
||||||
|
// being cached for later reuse — the server must stay alive. Only send
|
||||||
|
// the shutdown beacon when the page is truly being discarded.
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePageHide = (event: PageTransitionEvent) => {
|
||||||
|
if (event.persisted) {
|
||||||
|
// Page is entering bfcache (tab switch, app backgrounding) — keep
|
||||||
|
// the server alive so PTY sessions survive.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// sendBeacon cannot set custom headers, so pass the auth token as a
|
||||||
|
// query parameter instead (the proxy accepts `_token` as a fallback).
|
||||||
|
const token = getAuthToken()
|
||||||
|
const url = token ? `/api/shutdown?_token=${token}` : "/api/shutdown"
|
||||||
|
navigator.sendBeacon(url, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pagehide", handlePageHide)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pagehide", handlePageHide)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// No project selected yet — show project selection gate
|
||||||
|
if (!activeProjectCwd || !activeStore) {
|
||||||
|
return <ProjectSelectionGate />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GSDWorkspaceProvider store={activeStore}>
|
||||||
|
<DevOverridesProvider>
|
||||||
|
<WorkspaceChrome />
|
||||||
|
</DevOverridesProvider>
|
||||||
|
</GSDWorkspaceProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
2346
web/components/sf/chat-mode.tsx
Normal file
2346
web/components/sf/chat-mode.tsx
Normal file
File diff suppressed because it is too large
Load diff
221
web/components/sf/code-editor.tsx
Normal file
221
web/components/sf/code-editor.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import dynamic from "next/dynamic"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
import { createTheme } from "@uiw/codemirror-themes"
|
||||||
|
import { tags as t } from "@lezer/highlight"
|
||||||
|
import { loadLanguage, type LanguageName } from "@uiw/codemirror-extensions-langs"
|
||||||
|
import { EditorView } from "@codemirror/view"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/* ── Dynamic import (no SSR — CodeMirror needs browser DOM) ── */
|
||||||
|
|
||||||
|
const ReactCodeMirror = dynamic(() => import("@uiw/react-codemirror"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-full min-h-[120px] items-center justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ── Syntax highlighting styles ── */
|
||||||
|
|
||||||
|
const darkStyles = [
|
||||||
|
{ tag: [t.comment, t.lineComment, t.blockComment], color: "#6a737d" },
|
||||||
|
{ tag: [t.keyword], color: "#ff7b72" },
|
||||||
|
{ tag: [t.operator], color: "#79c0ff" },
|
||||||
|
{ tag: [t.string, t.special(t.string)], color: "#a5d6ff" },
|
||||||
|
{ tag: [t.number, t.bool, t.null], color: "#79c0ff" },
|
||||||
|
{ tag: [t.variableName], color: "#c9d1d9" },
|
||||||
|
{ tag: [t.definition(t.variableName)], color: "#d2a8ff" },
|
||||||
|
{ tag: [t.function(t.variableName)], color: "#d2a8ff" },
|
||||||
|
{ tag: [t.typeName, t.className], color: "#ffa657" },
|
||||||
|
{ tag: [t.propertyName], color: "#79c0ff" },
|
||||||
|
{ tag: [t.definition(t.propertyName)], color: "#c9d1d9" },
|
||||||
|
{ tag: [t.bracket], color: "#8b949e" },
|
||||||
|
{ tag: [t.punctuation], color: "#8b949e" },
|
||||||
|
{ tag: [t.tagName], color: "#7ee787" },
|
||||||
|
{ tag: [t.attributeName], color: "#79c0ff" },
|
||||||
|
{ tag: [t.attributeValue], color: "#a5d6ff" },
|
||||||
|
{ tag: [t.regexp], color: "#7ee787" },
|
||||||
|
{ tag: [t.escape], color: "#79c0ff" },
|
||||||
|
{ tag: [t.meta], color: "#8b949e" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const lightStyles = [
|
||||||
|
{ tag: [t.comment, t.lineComment, t.blockComment], color: "#6a737d" },
|
||||||
|
{ tag: [t.keyword], color: "#cf222e" },
|
||||||
|
{ tag: [t.operator], color: "#0550ae" },
|
||||||
|
{ tag: [t.string, t.special(t.string)], color: "#0a3069" },
|
||||||
|
{ tag: [t.number, t.bool, t.null], color: "#0550ae" },
|
||||||
|
{ tag: [t.variableName], color: "#24292f" },
|
||||||
|
{ tag: [t.definition(t.variableName)], color: "#8250df" },
|
||||||
|
{ tag: [t.function(t.variableName)], color: "#8250df" },
|
||||||
|
{ tag: [t.typeName, t.className], color: "#953800" },
|
||||||
|
{ tag: [t.propertyName], color: "#0550ae" },
|
||||||
|
{ tag: [t.definition(t.propertyName)], color: "#24292f" },
|
||||||
|
{ tag: [t.bracket], color: "#57606a" },
|
||||||
|
{ tag: [t.punctuation], color: "#57606a" },
|
||||||
|
{ tag: [t.tagName], color: "#116329" },
|
||||||
|
{ tag: [t.attributeName], color: "#0550ae" },
|
||||||
|
{ tag: [t.attributeValue], color: "#0a3069" },
|
||||||
|
{ tag: [t.regexp], color: "#116329" },
|
||||||
|
{ tag: [t.escape], color: "#0550ae" },
|
||||||
|
{ tag: [t.meta], color: "#57606a" },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* ── Static theme objects (module-level, never recreated on render) ── */
|
||||||
|
|
||||||
|
const darkTheme = createTheme({
|
||||||
|
theme: "dark",
|
||||||
|
settings: {
|
||||||
|
background: "oklch(0.09 0 0)",
|
||||||
|
foreground: "oklch(0.9 0 0)",
|
||||||
|
caret: "oklch(0.9 0 0)",
|
||||||
|
selection: "oklch(0.2 0 0)",
|
||||||
|
lineHighlight: "oklch(0.12 0 0)",
|
||||||
|
gutterBackground: "oklch(0.09 0 0)",
|
||||||
|
gutterForeground: "oklch(0.42 0 0)",
|
||||||
|
gutterBorder: "transparent",
|
||||||
|
},
|
||||||
|
styles: darkStyles,
|
||||||
|
})
|
||||||
|
|
||||||
|
const lightTheme = createTheme({
|
||||||
|
theme: "light",
|
||||||
|
settings: {
|
||||||
|
background: "oklch(0.98 0 0)",
|
||||||
|
foreground: "oklch(0.15 0 0)",
|
||||||
|
caret: "oklch(0.15 0 0)",
|
||||||
|
selection: "oklch(0.9 0 0)",
|
||||||
|
lineHighlight: "oklch(0.96 0 0)",
|
||||||
|
gutterBackground: "oklch(0.98 0 0)",
|
||||||
|
gutterForeground: "oklch(0.55 0 0)",
|
||||||
|
gutterBorder: "transparent",
|
||||||
|
},
|
||||||
|
styles: lightStyles,
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ── Language mapping (shiki lang names → CodeMirror loadLanguage names) ── */
|
||||||
|
|
||||||
|
const CM_LANG_MAP: Record<string, LanguageName | null> = {
|
||||||
|
// TypeScript / JavaScript family
|
||||||
|
typescript: "ts",
|
||||||
|
tsx: "tsx",
|
||||||
|
javascript: "js",
|
||||||
|
jsx: "jsx",
|
||||||
|
// Shell variants
|
||||||
|
bash: "bash",
|
||||||
|
sh: "sh",
|
||||||
|
zsh: "sh",
|
||||||
|
// Data formats
|
||||||
|
json: "json",
|
||||||
|
jsonc: "json",
|
||||||
|
yaml: "yaml",
|
||||||
|
toml: "toml",
|
||||||
|
// Markup
|
||||||
|
markdown: "markdown",
|
||||||
|
mdx: "markdown", // CM has no mdx — use markdown
|
||||||
|
html: "html",
|
||||||
|
xml: "xml",
|
||||||
|
// Styles
|
||||||
|
css: "css",
|
||||||
|
scss: "scss",
|
||||||
|
less: "less",
|
||||||
|
// Systems
|
||||||
|
python: "py",
|
||||||
|
ruby: "rb",
|
||||||
|
rust: "rs",
|
||||||
|
go: "go",
|
||||||
|
java: "java",
|
||||||
|
kotlin: "kt",
|
||||||
|
swift: "swift",
|
||||||
|
c: "c",
|
||||||
|
cpp: "cpp",
|
||||||
|
csharp: "cs",
|
||||||
|
// Other
|
||||||
|
php: "php",
|
||||||
|
sql: "sql",
|
||||||
|
graphql: null, // CM has no graphql support
|
||||||
|
dockerfile: null, // CM has no dockerfile support
|
||||||
|
makefile: null, // CM has no makefile support
|
||||||
|
lua: "lua",
|
||||||
|
r: "r",
|
||||||
|
latex: "tex",
|
||||||
|
diff: "diff",
|
||||||
|
// No CM equivalent → plain text
|
||||||
|
viml: null,
|
||||||
|
dotenv: null,
|
||||||
|
fish: null,
|
||||||
|
ini: "ini",
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Component ── */
|
||||||
|
|
||||||
|
interface CodeEditorProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
language: string | null
|
||||||
|
fontSize: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
language,
|
||||||
|
fontSize,
|
||||||
|
className,
|
||||||
|
}: CodeEditorProps) {
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
const theme = resolvedTheme !== "light" ? darkTheme : lightTheme
|
||||||
|
|
||||||
|
// Resolve and cache language extension
|
||||||
|
const langExtension = useMemo(() => {
|
||||||
|
if (!language) return null
|
||||||
|
const cmName = CM_LANG_MAP[language]
|
||||||
|
if (cmName === undefined || cmName === null) return null
|
||||||
|
return loadLanguage(cmName)
|
||||||
|
}, [language])
|
||||||
|
|
||||||
|
// Font size extension
|
||||||
|
const fontSizeExt = useMemo(
|
||||||
|
() =>
|
||||||
|
EditorView.theme({
|
||||||
|
"&": { fontSize: `${fontSize}px` },
|
||||||
|
".cm-gutters": { fontSize: `${fontSize}px` },
|
||||||
|
}),
|
||||||
|
[fontSize],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Combined extensions (memoized to avoid re-initialization)
|
||||||
|
const extensions = useMemo(() => {
|
||||||
|
const exts = [fontSizeExt]
|
||||||
|
if (langExtension) exts.push(langExtension)
|
||||||
|
return exts
|
||||||
|
}, [fontSizeExt, langExtension])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactCodeMirror
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
theme={theme}
|
||||||
|
extensions={extensions}
|
||||||
|
height="100%"
|
||||||
|
basicSetup={{
|
||||||
|
lineNumbers: true,
|
||||||
|
highlightActiveLine: true,
|
||||||
|
highlightActiveLineGutter: true,
|
||||||
|
foldGutter: true,
|
||||||
|
bracketMatching: true,
|
||||||
|
closeBrackets: true,
|
||||||
|
autocompletion: false,
|
||||||
|
tabSize: 2,
|
||||||
|
}}
|
||||||
|
className={cn("overflow-hidden rounded-md border", className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
2341
web/components/sf/command-surface.tsx
Normal file
2341
web/components/sf/command-surface.tsx
Normal file
File diff suppressed because it is too large
Load diff
442
web/components/sf/dashboard.tsx
Normal file
442
web/components/sf/dashboard.tsx
Normal file
|
|
@ -0,0 +1,442 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react"
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Clock,
|
||||||
|
DollarSign,
|
||||||
|
Zap,
|
||||||
|
CheckCircle2,
|
||||||
|
Circle,
|
||||||
|
Play,
|
||||||
|
GitBranch,
|
||||||
|
TrendingDown,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
useGSDWorkspaceState,
|
||||||
|
useGSDWorkspaceActions,
|
||||||
|
buildPromptCommand,
|
||||||
|
buildProjectUrl,
|
||||||
|
formatDuration,
|
||||||
|
formatCost,
|
||||||
|
formatTokens,
|
||||||
|
getCurrentScopeLabel,
|
||||||
|
getCurrentBranch,
|
||||||
|
getCurrentSlice,
|
||||||
|
getLiveAutoDashboard,
|
||||||
|
getLiveWorkspaceIndex,
|
||||||
|
type WorkspaceTerminalLine,
|
||||||
|
type TerminalLineType,
|
||||||
|
} from "@/lib/sf-workspace-store"
|
||||||
|
import { getTaskStatus, type ItemStatus } from "@/lib/workspace-status"
|
||||||
|
import { deriveWorkflowAction } from "@/lib/workflow-actions"
|
||||||
|
import { executeWorkflowActionInPowerMode } from "@/lib/workflow-action-execution"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
CurrentSliceCardSkeleton,
|
||||||
|
ActivityCardSkeleton,
|
||||||
|
} from "@/components/sf/loading-skeletons"
|
||||||
|
import { ScopeBadge } from "@/components/sf/scope-badge"
|
||||||
|
import { ProjectWelcome } from "@/components/sf/project-welcome"
|
||||||
|
import { authFetch } from "@/lib/auth"
|
||||||
|
import { type ProjectTotals } from "@/lib/visualizer-types"
|
||||||
|
|
||||||
|
/** Interpolate progress bar color from red (0%) through yellow (50%) to green (100%) using oklch. */
|
||||||
|
function getProgressColor(percent: number): string {
|
||||||
|
const p = Math.max(0, Math.min(100, percent))
|
||||||
|
// Hue: 25 (red) → 85 (yellow) at 50% → 145 (green) at 100%
|
||||||
|
const hue = 25 + (p / 100) * 120
|
||||||
|
return `oklch(0.65 0.16 ${hue.toFixed(1)})`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
label: string
|
||||||
|
value: string | null
|
||||||
|
subtext?: string | null
|
||||||
|
icon: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ label, value, subtext, icon }: MetricCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-card p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
{value === null ? (
|
||||||
|
<>
|
||||||
|
<Skeleton className="mt-2 h-7 w-20" />
|
||||||
|
<Skeleton className="mt-1.5 h-3 w-16" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mt-1 truncate text-2xl font-semibold tracking-tight">{value}</p>
|
||||||
|
{subtext && <p className="mt-0.5 truncate text-xs text-muted-foreground">{subtext}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-md bg-accent p-2 text-muted-foreground">{icon}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskStatusIcon(status: ItemStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case "done":
|
||||||
|
return <CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
case "in-progress":
|
||||||
|
return <Play className="h-4 w-4 text-foreground" />
|
||||||
|
case "pending":
|
||||||
|
return <Circle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityDotColor(type: TerminalLineType): string {
|
||||||
|
switch (type) {
|
||||||
|
case "success":
|
||||||
|
return "bg-success"
|
||||||
|
case "error":
|
||||||
|
return "bg-destructive"
|
||||||
|
default:
|
||||||
|
return "bg-foreground/50"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardProps {
|
||||||
|
onSwitchView?: (view: string) => void
|
||||||
|
onExpandTerminal?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {}) {
|
||||||
|
const state = useGSDWorkspaceState()
|
||||||
|
const { sendCommand } = useGSDWorkspaceActions()
|
||||||
|
const boot = state.boot
|
||||||
|
const workspace = getLiveWorkspaceIndex(state)
|
||||||
|
const auto = getLiveAutoDashboard(state)
|
||||||
|
const bridge = boot?.bridge ?? null
|
||||||
|
const freshness = state.live.freshness
|
||||||
|
const projectCwd = boot?.project.cwd
|
||||||
|
|
||||||
|
// ── Project-level totals from visualizer API ──
|
||||||
|
// Provides fallback metrics when auto-mode is not active (#2709).
|
||||||
|
// Same polling pattern as status-bar.tsx.
|
||||||
|
const [projectTotals, setProjectTotals] = useState<ProjectTotals | null>(null)
|
||||||
|
|
||||||
|
const fetchProjectTotals = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await authFetch(buildProjectUrl("/api/visualizer", projectCwd))
|
||||||
|
if (!resp.ok) return
|
||||||
|
const json = await resp.json()
|
||||||
|
if (json.totals) setProjectTotals(json.totals)
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — dashboard metrics are non-critical
|
||||||
|
}
|
||||||
|
}, [projectCwd])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
void fetchProjectTotals()
|
||||||
|
}, 0)
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void fetchProjectTotals()
|
||||||
|
}, 30_000)
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
window.clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [fetchProjectTotals])
|
||||||
|
|
||||||
|
const elapsed = projectTotals?.duration ?? auto?.elapsed ?? 0
|
||||||
|
const totalCost = projectTotals?.cost ?? auto?.totalCost ?? 0
|
||||||
|
const totalTokens = projectTotals?.tokens.total ?? auto?.totalTokens ?? 0
|
||||||
|
const rtkSavings = auto?.rtkSavings ?? null
|
||||||
|
const rtkEnabled = auto?.rtkEnabled === true
|
||||||
|
|
||||||
|
const currentSlice = getCurrentSlice(workspace)
|
||||||
|
const doneTasks = currentSlice?.tasks.filter((t) => t.done).length ?? 0
|
||||||
|
const totalTasks = currentSlice?.tasks.length ?? 0
|
||||||
|
const progressPercent = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0
|
||||||
|
|
||||||
|
const scopeLabel = getCurrentScopeLabel(workspace)
|
||||||
|
const branch = getCurrentBranch(workspace)
|
||||||
|
const isAutoActive = auto?.active ?? false
|
||||||
|
const currentUnitLabel = auto?.currentUnit?.id ?? scopeLabel
|
||||||
|
const currentUnitFreshness = freshness.auto.stale ? "stale" : freshness.auto.status
|
||||||
|
|
||||||
|
const workflowAction = deriveWorkflowAction({
|
||||||
|
phase: workspace?.active.phase ?? "pre-planning",
|
||||||
|
autoActive: auto?.active ?? false,
|
||||||
|
autoPaused: auto?.paused ?? false,
|
||||||
|
onboardingLocked: boot?.onboarding.locked ?? false,
|
||||||
|
commandInFlight: state.commandInFlight,
|
||||||
|
bootStatus: state.bootStatus,
|
||||||
|
hasMilestones: (workspace?.milestones.length ?? 0) > 0,
|
||||||
|
projectDetectionKind: boot?.projectDetection?.kind ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleWorkflowAction = (command: string) => {
|
||||||
|
executeWorkflowActionInPowerMode({
|
||||||
|
dispatch: () => sendCommand(buildPromptCommand(command, bridge)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePrimaryAction = () => {
|
||||||
|
if (!workflowAction.primary) return
|
||||||
|
handleWorkflowAction(workflowAction.primary.command)
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentLines: WorkspaceTerminalLine[] = (state.terminalLines ?? []).slice(-6)
|
||||||
|
const isConnecting = state.bootStatus === "idle" || state.bootStatus === "loading"
|
||||||
|
|
||||||
|
const rtkValue = isConnecting ? null : formatTokens(rtkSavings?.savedTokens ?? 0)
|
||||||
|
const rtkSubtext = isConnecting
|
||||||
|
? null
|
||||||
|
: rtkSavings && rtkSavings.commands > 0
|
||||||
|
? `${Math.round(rtkSavings.savingsPct)}% saved • ${rtkSavings.commands} cmd${rtkSavings.commands === 1 ? "" : "s"}`
|
||||||
|
: "Waiting for shell usage"
|
||||||
|
|
||||||
|
// ─── Project Welcome Gate ───────────────────────────────────────────
|
||||||
|
// Show welcome screen for projects that aren't initialized with GSD yet
|
||||||
|
const detection = boot?.projectDetection
|
||||||
|
const showWelcome =
|
||||||
|
!isConnecting &&
|
||||||
|
detection &&
|
||||||
|
detection.kind !== "active-gsd" &&
|
||||||
|
detection.kind !== "empty-gsd"
|
||||||
|
|
||||||
|
if (showWelcome) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
<ProjectWelcome
|
||||||
|
detection={detection}
|
||||||
|
onCommand={(cmd) => handleWorkflowAction(cmd)}
|
||||||
|
onSwitchView={(view) => onSwitchView?.(view)}
|
||||||
|
disabled={!!state.commandInFlight || boot?.onboarding.locked}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-3 py-2 md:px-6 md:py-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<h1 className="text-base md:text-lg font-semibold shrink-0">Dashboard</h1>
|
||||||
|
{!isConnecting && scopeLabel && (
|
||||||
|
<>
|
||||||
|
<span className="hidden sm:inline text-lg font-thin text-muted-foreground select-none">/</span>
|
||||||
|
<span className="hidden sm:inline"><ScopeBadge label={scopeLabel} size="sm" /></span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isConnecting && <Skeleton className="h-4 w-40" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 md:gap-3" data-testid="dashboard-action-bar">
|
||||||
|
{isConnecting ? (
|
||||||
|
<>
|
||||||
|
<Skeleton className="h-8 w-40 rounded-md" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{!isConnecting && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-border bg-card px-3 py-1.5 text-sm">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 rounded-full",
|
||||||
|
isAutoActive ? "animate-pulse bg-success" : "bg-muted-foreground/50",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">
|
||||||
|
{isAutoActive ? "Auto Mode Active" : "Auto Mode Inactive"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isConnecting && branch && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<GitBranch className="h-4 w-4" />
|
||||||
|
<span className="font-mono">{branch}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 md:p-6">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
|
||||||
|
<div className="rounded-md border border-border bg-card p-4" data-testid="dashboard-current-unit">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Current Unit</p>
|
||||||
|
{isConnecting ? (
|
||||||
|
<>
|
||||||
|
<Skeleton className="mt-2 h-7 w-20" />
|
||||||
|
<Skeleton className="mt-1.5 h-3 w-16" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-2">
|
||||||
|
<ScopeBadge label={currentUnitLabel} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-xs text-muted-foreground" data-testid="dashboard-current-unit-freshness">
|
||||||
|
Auto freshness: {currentUnitFreshness}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-md bg-accent p-2 text-muted-foreground">
|
||||||
|
<Activity className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MetricCard
|
||||||
|
label="Elapsed Time"
|
||||||
|
value={isConnecting ? null : formatDuration(elapsed)}
|
||||||
|
icon={<Clock className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Total Cost"
|
||||||
|
value={isConnecting ? null : formatCost(totalCost)}
|
||||||
|
icon={<DollarSign className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Tokens Used"
|
||||||
|
value={isConnecting ? null : formatTokens(totalTokens)}
|
||||||
|
icon={<Zap className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
{rtkEnabled && (
|
||||||
|
<MetricCard
|
||||||
|
label="RTK Saved"
|
||||||
|
value={rtkValue}
|
||||||
|
subtext={rtkSubtext}
|
||||||
|
icon={<TrendingDown className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{/* Current Slice */}
|
||||||
|
{isConnecting ? (
|
||||||
|
<CurrentSliceCardSkeleton />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col rounded-md border border-border bg-card">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-border px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Current Slice</h2>
|
||||||
|
{currentSlice ? (
|
||||||
|
<p className="mt-0.5 truncate text-sm font-medium text-foreground">
|
||||||
|
{currentSlice.id} — {currentSlice.title}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-0.5 text-sm text-muted-foreground">No active slice</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{currentSlice && totalTasks > 0 && (
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<span className="text-2xl font-bold tabular-nums leading-none">{progressPercent}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{currentSlice && totalTasks > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="h-1 w-full overflow-hidden rounded-full bg-accent">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${progressPercent}%`, backgroundColor: getProgressColor(progressPercent) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-xs text-muted-foreground">{doneTasks} of {totalTasks} tasks complete</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Task list */}
|
||||||
|
<div className="flex-1 p-3">
|
||||||
|
{currentSlice && currentSlice.tasks.length > 0 ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{currentSlice.tasks.map((task) => {
|
||||||
|
const status = getTaskStatus(
|
||||||
|
workspace!.active.milestoneId!,
|
||||||
|
currentSlice.id,
|
||||||
|
task,
|
||||||
|
workspace!.active,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors",
|
||||||
|
status === "in-progress" && "bg-accent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{taskStatusIcon(status)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1 truncate text-xs",
|
||||||
|
status === "done" && "text-muted-foreground line-through decoration-muted-foreground/40",
|
||||||
|
status === "pending" && "text-muted-foreground",
|
||||||
|
status === "in-progress" && "font-medium text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-muted-foreground">{task.id}</span>
|
||||||
|
<span className="mx-1.5 text-border">·</span>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
{status === "in-progress" && (
|
||||||
|
<span className="shrink-0 rounded-sm bg-foreground/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="px-2 py-2 text-xs text-muted-foreground">
|
||||||
|
No active slice or no tasks defined yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isConnecting ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<ActivityCardSkeleton />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 rounded-md border border-border bg-card">
|
||||||
|
<div className="border-b border-border px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold">Recent Activity</h2>
|
||||||
|
</div>
|
||||||
|
{recentLines.length > 0 ? (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{recentLines.map((line) => (
|
||||||
|
<div key={line.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||||
|
<span className="w-16 flex-shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
|
{line.timestamp}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
|
||||||
|
activityDotColor(line.type),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate text-sm">{line.content}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
No activity yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
523
web/components/sf/diagnostics-panels.tsx
Normal file
523
web/components/sf/diagnostics-panels.tsx
Normal file
|
|
@ -0,0 +1,523 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertTriangle, CheckCircle2, Info, LoaderCircle, RefreshCw, ShieldAlert, Wrench, XCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import type {
|
||||||
|
DoctorIssue,
|
||||||
|
ForensicAnomaly,
|
||||||
|
ForensicReport,
|
||||||
|
DoctorReport,
|
||||||
|
SkillHealthReport,
|
||||||
|
SkillHealSuggestion,
|
||||||
|
} from "@/lib/diagnostics-types"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
formatCost,
|
||||||
|
useGSDWorkspaceActions,
|
||||||
|
useGSDWorkspaceState,
|
||||||
|
} from "@/lib/sf-workspace-store"
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SHARED
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function SeverityIcon({ severity, className }: { severity: "info" | "warning" | "error" | "critical"; className?: string }) {
|
||||||
|
const base = cn("h-3.5 w-3.5 shrink-0", className)
|
||||||
|
switch (severity) {
|
||||||
|
case "error":
|
||||||
|
case "critical":
|
||||||
|
return <XCircle className={cn(base, "text-destructive")} />
|
||||||
|
case "warning":
|
||||||
|
return <AlertTriangle className={cn(base, "text-warning")} />
|
||||||
|
default:
|
||||||
|
return <Info className={cn(base, "text-info")} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityBadgeVariant(s: string): "destructive" | "secondary" | "outline" {
|
||||||
|
if (s === "error" || s === "critical") return "destructive"
|
||||||
|
if (s === "warning") return "secondary"
|
||||||
|
return "outline"
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
status,
|
||||||
|
onRefresh,
|
||||||
|
refreshing,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
subtitle?: string | null
|
||||||
|
status?: React.ReactNode
|
||||||
|
onRefresh: () => void
|
||||||
|
refreshing: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 pb-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{title}</h3>
|
||||||
|
{status}
|
||||||
|
{subtitle && <span className="text-[11px] text-muted-foreground">{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing} className="h-7 gap-1.5 text-xs">
|
||||||
|
<RefreshCw className={cn("h-3 w-3", refreshing && "animate-spin")} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagError({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-xs text-destructive">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagLoading({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-6 text-xs text-muted-foreground">
|
||||||
|
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagEmpty({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/50 bg-card/50 px-4 py-5 text-center text-xs text-muted-foreground">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatPill({ label, value, variant }: { label: string; value: number | string; variant?: "default" | "error" | "warning" | "info" }) {
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs",
|
||||||
|
variant === "error" && "border-destructive/20 bg-destructive/5 text-destructive",
|
||||||
|
variant === "warning" && "border-warning/20 bg-warning/5 text-warning",
|
||||||
|
variant === "info" && "border-info/20 bg-info/5 text-info",
|
||||||
|
(!variant || variant === "default") && "border-border/50 bg-card/50 text-foreground/80",
|
||||||
|
)}>
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-medium tabular-nums">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// FORENSICS PANEL
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function AnomalyRow({ anomaly }: { anomaly: ForensicAnomaly }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SeverityIcon severity={anomaly.severity} />
|
||||||
|
<Badge variant={severityBadgeVariant(anomaly.severity)} className="text-[10px] px-1.5 py-0">{anomaly.severity}</Badge>
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono">{anomaly.type}</Badge>
|
||||||
|
{anomaly.unitId && (
|
||||||
|
<span className="text-[10px] text-muted-foreground font-mono truncate">{anomaly.unitType}/{anomaly.unitId}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-foreground">{anomaly.summary}</p>
|
||||||
|
{anomaly.details && anomaly.details !== anomaly.summary && (
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-relaxed">{anomaly.details}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForensicsPanel() {
|
||||||
|
const workspace = useGSDWorkspaceState()
|
||||||
|
const { loadForensicsDiagnostics } = useGSDWorkspaceActions()
|
||||||
|
const state = workspace.commandSurface.diagnostics.forensics
|
||||||
|
const data = state.data as ForensicReport | null
|
||||||
|
const busy = state.phase === "loading"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="diagnostics-forensics">
|
||||||
|
<DiagHeader
|
||||||
|
title="Forensic Analysis"
|
||||||
|
subtitle={data ? new Date(data.timestamp).toLocaleString() : null}
|
||||||
|
status={data ? (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-block h-1.5 w-1.5 rounded-full",
|
||||||
|
data.anomalies.length > 0 ? "bg-warning" : "bg-success",
|
||||||
|
)} />
|
||||||
|
) : null}
|
||||||
|
onRefresh={() => void loadForensicsDiagnostics()}
|
||||||
|
refreshing={busy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{state.error && <DiagError message={state.error} />}
|
||||||
|
{busy && !data && <DiagLoading label="Running forensic analysis…" />}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
{/* Metrics summary */}
|
||||||
|
{data.metrics && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<StatPill label="Units" value={data.metrics.totalUnits} />
|
||||||
|
<StatPill label="Cost" value={formatCost(data.metrics.totalCost)} />
|
||||||
|
<StatPill label="Duration" value={`${Math.round(data.metrics.totalDuration / 1000)}s`} />
|
||||||
|
<StatPill label="Traces" value={data.unitTraceCount} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Crash lock */}
|
||||||
|
{data.crashLock ? (
|
||||||
|
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2.5 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldAlert className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
<span className="text-xs font-medium text-destructive">Crash Lock Active</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5 text-[11px]">
|
||||||
|
<span className="text-muted-foreground">PID</span>
|
||||||
|
<span className="font-mono text-foreground/80">{data.crashLock.pid}</span>
|
||||||
|
<span className="text-muted-foreground">Started</span>
|
||||||
|
<span className="text-foreground/80">{new Date(data.crashLock.startedAt).toLocaleString()}</span>
|
||||||
|
<span className="text-muted-foreground">Unit</span>
|
||||||
|
<span className="font-mono text-foreground/80">{data.crashLock.unitType}/{data.crashLock.unitId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
|
||||||
|
No crash lock
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Anomalies */}
|
||||||
|
{data.anomalies.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground">Anomalies ({data.anomalies.length})</h4>
|
||||||
|
{data.anomalies.map((a, i) => <AnomalyRow key={i} anomaly={a} />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DiagEmpty message="No anomalies detected" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent units */}
|
||||||
|
{data.recentUnits.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground">Recent Units ({data.recentUnits.length})</h4>
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border/50">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border/50 bg-card/50">
|
||||||
|
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Type</th>
|
||||||
|
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">ID</th>
|
||||||
|
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Model</th>
|
||||||
|
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Cost</th>
|
||||||
|
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.recentUnits.map((u, i) => (
|
||||||
|
<tr key={i} className="border-b border-border/50 last:border-0">
|
||||||
|
<td className="px-2.5 py-1.5 font-mono text-foreground/80">{u.type}</td>
|
||||||
|
<td className="px-2.5 py-1.5 font-mono text-foreground/80 truncate max-w-[120px]">{u.id}</td>
|
||||||
|
<td className="px-2.5 py-1.5 text-muted-foreground">{u.model}</td>
|
||||||
|
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{formatCost(u.cost)}</td>
|
||||||
|
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{Math.round(u.duration / 1000)}s</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// DOCTOR PANEL
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function humanizeCode(code: string): string {
|
||||||
|
return code.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueRow({ issue }: { issue: DoctorIssue }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<SeverityIcon severity={issue.severity} />
|
||||||
|
<Badge variant={severityBadgeVariant(issue.severity)} className="text-[10px] px-1.5 py-0">{issue.severity}</Badge>
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono">{humanizeCode(issue.code)}</Badge>
|
||||||
|
{issue.scope && <span className="text-[10px] text-muted-foreground font-mono">{issue.scope}</span>}
|
||||||
|
{issue.fixable && (
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-success/30 text-success">
|
||||||
|
<Wrench className="h-2.5 w-2.5 mr-0.5" />fixable
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-foreground">{issue.message}</p>
|
||||||
|
{issue.file && <p className="text-[10px] font-mono text-muted-foreground truncate">{issue.file}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DoctorPanel() {
|
||||||
|
const workspace = useGSDWorkspaceState()
|
||||||
|
const { loadDoctorDiagnostics, applyDoctorFixes } = useGSDWorkspaceActions()
|
||||||
|
const state = workspace.commandSurface.diagnostics.doctor
|
||||||
|
const data = state.data as DoctorReport | null
|
||||||
|
const busy = state.phase === "loading"
|
||||||
|
|
||||||
|
const fixableCount = data?.summary.fixable ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="diagnostics-doctor">
|
||||||
|
<DiagHeader
|
||||||
|
title="Doctor Health Check"
|
||||||
|
status={data ? (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-block h-1.5 w-1.5 rounded-full",
|
||||||
|
data.ok ? "bg-success" : "bg-destructive",
|
||||||
|
)} />
|
||||||
|
) : null}
|
||||||
|
onRefresh={() => void loadDoctorDiagnostics()}
|
||||||
|
refreshing={busy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{state.error && <DiagError message={state.error} />}
|
||||||
|
{busy && !data && <DiagLoading label="Running health check…" />}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
{/* Summary bar */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<StatPill label="Total" value={data.summary.total} />
|
||||||
|
{data.summary.errors > 0 && <StatPill label="Errors" value={data.summary.errors} variant="error" />}
|
||||||
|
{data.summary.warnings > 0 && <StatPill label="Warnings" value={data.summary.warnings} variant="warning" />}
|
||||||
|
{data.summary.infos > 0 && <StatPill label="Info" value={data.summary.infos} variant="info" />}
|
||||||
|
{fixableCount > 0 && (
|
||||||
|
<StatPill label="Fixable" value={fixableCount} variant="info" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apply fixes button */}
|
||||||
|
{fixableCount > 0 && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void applyDoctorFixes()}
|
||||||
|
disabled={state.fixPending}
|
||||||
|
className="h-7 gap-1.5 text-xs"
|
||||||
|
data-testid="doctor-apply-fixes"
|
||||||
|
>
|
||||||
|
{state.fixPending ? (
|
||||||
|
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Wrench className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Apply Fixes ({fixableCount})
|
||||||
|
</Button>
|
||||||
|
{state.lastFixError && (
|
||||||
|
<span className="text-[11px] text-destructive">{state.lastFixError}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fix results */}
|
||||||
|
{state.lastFixResult && state.lastFixResult.fixesApplied.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-success/20 bg-success/5 px-3 py-2.5 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
|
||||||
|
<span className="text-xs font-medium text-success">Fixes Applied</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5 pl-5">
|
||||||
|
{state.lastFixResult.fixesApplied.map((fix, i) => (
|
||||||
|
<li key={i} className="text-[11px] text-foreground/80 list-disc">{fix}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Issue list */}
|
||||||
|
{data.issues.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground">Issues ({data.issues.length})</h4>
|
||||||
|
{data.issues.map((issue, i) => <IssueRow key={i} issue={issue} />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DiagEmpty message="No issues found — workspace is healthy" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SKILL HEALTH PANEL
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function trendArrow(trend: "stable" | "rising" | "declining"): string {
|
||||||
|
if (trend === "rising") return "↑"
|
||||||
|
if (trend === "declining") return "↓"
|
||||||
|
return "→"
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendColor(trend: "stable" | "rising" | "declining"): string {
|
||||||
|
if (trend === "rising") return "text-warning"
|
||||||
|
if (trend === "declining") return "text-destructive"
|
||||||
|
return "text-muted-foreground"
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuggestionRow({ suggestion }: { suggestion: SkillHealSuggestion }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<SeverityIcon severity={suggestion.severity} />
|
||||||
|
<Badge variant={severityBadgeVariant(suggestion.severity)} className="text-[10px] px-1.5 py-0">{suggestion.severity}</Badge>
|
||||||
|
<span className="text-[11px] font-medium text-foreground/80">{suggestion.skillName}</span>
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono">{suggestion.trigger.replace(/_/g, " ")}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-foreground">{suggestion.message}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillHealthPanel() {
|
||||||
|
const workspace = useGSDWorkspaceState()
|
||||||
|
const { loadSkillHealthDiagnostics } = useGSDWorkspaceActions()
|
||||||
|
const state = workspace.commandSurface.diagnostics.skillHealth
|
||||||
|
const data = state.data as SkillHealthReport | null
|
||||||
|
const busy = state.phase === "loading"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="diagnostics-skill-health">
|
||||||
|
<DiagHeader
|
||||||
|
title="Skill Health"
|
||||||
|
subtitle={data ? new Date(data.generatedAt).toLocaleString() : null}
|
||||||
|
status={data ? (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-block h-1.5 w-1.5 rounded-full",
|
||||||
|
data.decliningSkills.length > 0 ? "bg-warning" : "bg-success",
|
||||||
|
)} />
|
||||||
|
) : null}
|
||||||
|
onRefresh={() => void loadSkillHealthDiagnostics()}
|
||||||
|
refreshing={busy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{state.error && <DiagError message={state.error} />}
|
||||||
|
{busy && !data && <DiagLoading label="Analyzing skill health…" />}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
{/* Stats bar */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<StatPill label="Skills" value={data.skills.length} />
|
||||||
|
{data.staleSkills.length > 0 && <StatPill label="Stale" value={data.staleSkills.length} variant="warning" />}
|
||||||
|
{data.decliningSkills.length > 0 && <StatPill label="Declining" value={data.decliningSkills.length} variant="error" />}
|
||||||
|
<StatPill label="Total units" value={data.totalUnitsWithSkills} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skill table */}
|
||||||
|
{data.skills.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground">Skills ({data.skills.length})</h4>
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border/50">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border/50 bg-card/50">
|
||||||
|
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Skill</th>
|
||||||
|
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Uses</th>
|
||||||
|
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Success</th>
|
||||||
|
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Tokens</th>
|
||||||
|
<th className="px-2.5 py-1.5 text-center font-medium text-muted-foreground">Trend</th>
|
||||||
|
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Stale</th>
|
||||||
|
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Cost</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.skills.map((skill) => (
|
||||||
|
<tr key={skill.name} className={cn(
|
||||||
|
"border-b border-border/50 last:border-0",
|
||||||
|
skill.flagged && "bg-destructive/3",
|
||||||
|
)}>
|
||||||
|
<td className="px-2.5 py-1.5 font-mono text-foreground/80">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{skill.name}
|
||||||
|
{skill.flagged && <AlertTriangle className="h-3 w-3 text-warning shrink-0" />}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{skill.totalUses}</td>
|
||||||
|
<td className={cn(
|
||||||
|
"px-2.5 py-1.5 text-right tabular-nums",
|
||||||
|
skill.successRate >= 0.9 ? "text-success" : skill.successRate >= 0.7 ? "text-warning" : "text-destructive",
|
||||||
|
)}>
|
||||||
|
{(skill.successRate * 100).toFixed(0)}%
|
||||||
|
</td>
|
||||||
|
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{Math.round(skill.avgTokens)}</td>
|
||||||
|
<td className={cn("px-2.5 py-1.5 text-center", trendColor(skill.tokenTrend))}>
|
||||||
|
{trendArrow(skill.tokenTrend)}
|
||||||
|
</td>
|
||||||
|
<td className={cn(
|
||||||
|
"px-2.5 py-1.5 text-right tabular-nums",
|
||||||
|
skill.staleDays > 30 ? "text-warning" : "text-foreground/80",
|
||||||
|
)}>
|
||||||
|
{skill.staleDays > 0 ? `${skill.staleDays}d` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{formatCost(skill.avgCost)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stale skills */}
|
||||||
|
{data.staleSkills.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground">Stale Skills</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{data.staleSkills.map((name) => (
|
||||||
|
<Badge key={name} variant="secondary" className="text-[10px] font-mono">{name}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Declining skills */}
|
||||||
|
{data.decliningSkills.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground">Declining Skills</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{data.decliningSkills.map((name) => (
|
||||||
|
<Badge key={name} variant="destructive" className="text-[10px] font-mono">{name}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suggestions */}
|
||||||
|
{data.suggestions.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground">Suggestions ({data.suggestions.length})</h4>
|
||||||
|
{data.suggestions.map((s, i) => <SuggestionRow key={i} suggestion={s} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.skills.length === 0 && data.suggestions.length === 0 && (
|
||||||
|
<DiagEmpty message="No skill usage data available" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
web/components/sf/dual-terminal.tsx
Normal file
119
web/components/sf/dual-terminal.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react"
|
||||||
|
import { GripVertical, Loader2 } from "lucide-react"
|
||||||
|
import { MainSessionTerminal } from "@/components/sf/main-session-terminal"
|
||||||
|
import { ShellTerminal } from "@/components/sf/shell-terminal"
|
||||||
|
import { useTerminalFontSize } from "@/lib/use-terminal-font-size"
|
||||||
|
import { useGSDWorkspaceState } from "@/lib/sf-workspace-store"
|
||||||
|
import { derivePendingWorkflowCommandLabel } from "@/lib/workflow-action-execution"
|
||||||
|
|
||||||
|
export function DualTerminal() {
|
||||||
|
const [splitPosition, setSplitPosition] = useState(50)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isDragging = useRef(false)
|
||||||
|
const [terminalFontSize] = useTerminalFontSize()
|
||||||
|
const workspace = useGSDWorkspaceState()
|
||||||
|
const projectCwd = workspace.boot?.project.cwd
|
||||||
|
const pendingCommandLabel = derivePendingWorkflowCommandLabel({
|
||||||
|
commandInFlight: workspace.commandInFlight,
|
||||||
|
terminalLines: workspace.terminalLines,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleMouseDown = () => {
|
||||||
|
isDragging.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isDragging.current || !containerRef.current) return
|
||||||
|
const rect = containerRef.current.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left
|
||||||
|
const percent = (x / rect.width) * 100
|
||||||
|
setSplitPosition(Math.max(20, Math.min(80, percent)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
isDragging.current = false
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("mousemove", handleMouseMove)
|
||||||
|
document.addEventListener("mouseup", handleMouseUp)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove)
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Prevent browser default file-open on drag/drop anywhere in the dual terminal.
|
||||||
|
// Uses native DOM listeners so xterm's internal DOM can't swallow the events first.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = rootRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const preventDragDefault = (e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture phase ensures we fire before any child element can consume the event
|
||||||
|
el.addEventListener("dragover", preventDragDefault, true)
|
||||||
|
el.addEventListener("drop", preventDragDefault, true)
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener("dragover", preventDragDefault, true)
|
||||||
|
el.removeEventListener("drop", preventDragDefault, true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="flex h-full flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
|
||||||
|
<span className="font-medium">Power User Mode</span>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
{pendingCommandLabel && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-primary"
|
||||||
|
data-testid="power-mode-pending-command"
|
||||||
|
title={pendingCommandLabel}
|
||||||
|
>
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Sending {pendingCommandLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>Left: Main Session TUI</span>
|
||||||
|
<span className="text-border">|</span>
|
||||||
|
<span>Right: Interactive GSD</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Split terminals */}
|
||||||
|
<div ref={containerRef} className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Left terminal - Main bridge native TUI */}
|
||||||
|
<div style={{ width: `${splitPosition}%` }} className="flex h-full min-w-0 flex-col overflow-hidden bg-terminal">
|
||||||
|
<MainSessionTerminal className="min-h-0 flex-1" fontSize={terminalFontSize} projectCwd={projectCwd} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div
|
||||||
|
className="flex w-1 cursor-col-resize items-center justify-center bg-border hover:bg-muted-foreground/30 transition-colors"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right terminal - Interactive GSD instance */}
|
||||||
|
<div style={{ width: `${100 - splitPosition}%` }} className="h-full min-w-0 overflow-hidden bg-terminal">
|
||||||
|
<ShellTerminal
|
||||||
|
className="h-full"
|
||||||
|
command="gsd"
|
||||||
|
sessionPrefix="gsd-interactive"
|
||||||
|
fontSize={terminalFontSize}
|
||||||
|
hideInitialGsdHeader
|
||||||
|
projectCwd={projectCwd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
740
web/components/sf/file-content-viewer.tsx
Normal file
740
web/components/sf/file-content-viewer.tsx
Normal file
|
|
@ -0,0 +1,740 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react"
|
||||||
|
import { Loader2, Save, X } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||||
|
import { CodeEditor } from "@/components/sf/code-editor"
|
||||||
|
import { useEditorFontSize } from "@/lib/use-editor-font-size"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
|
||||||
|
/* ── Language detection ── */
|
||||||
|
|
||||||
|
const EXT_TO_LANG: Record<string, string> = {
|
||||||
|
ts: "typescript",
|
||||||
|
tsx: "tsx",
|
||||||
|
js: "javascript",
|
||||||
|
jsx: "jsx",
|
||||||
|
mjs: "javascript",
|
||||||
|
cjs: "javascript",
|
||||||
|
json: "json",
|
||||||
|
jsonc: "jsonc",
|
||||||
|
md: "markdown",
|
||||||
|
mdx: "mdx",
|
||||||
|
css: "css",
|
||||||
|
scss: "scss",
|
||||||
|
less: "less",
|
||||||
|
html: "html",
|
||||||
|
htm: "html",
|
||||||
|
xml: "xml",
|
||||||
|
svg: "xml",
|
||||||
|
yaml: "yaml",
|
||||||
|
yml: "yaml",
|
||||||
|
toml: "toml",
|
||||||
|
sh: "bash",
|
||||||
|
bash: "bash",
|
||||||
|
zsh: "bash",
|
||||||
|
fish: "fish",
|
||||||
|
py: "python",
|
||||||
|
rb: "ruby",
|
||||||
|
rs: "rust",
|
||||||
|
go: "go",
|
||||||
|
java: "java",
|
||||||
|
kt: "kotlin",
|
||||||
|
swift: "swift",
|
||||||
|
c: "c",
|
||||||
|
cpp: "cpp",
|
||||||
|
h: "c",
|
||||||
|
hpp: "cpp",
|
||||||
|
cs: "csharp",
|
||||||
|
php: "php",
|
||||||
|
sql: "sql",
|
||||||
|
graphql: "graphql",
|
||||||
|
gql: "graphql",
|
||||||
|
dockerfile: "dockerfile",
|
||||||
|
makefile: "makefile",
|
||||||
|
lua: "lua",
|
||||||
|
vim: "viml",
|
||||||
|
r: "r",
|
||||||
|
tex: "latex",
|
||||||
|
diff: "diff",
|
||||||
|
ini: "ini",
|
||||||
|
conf: "ini",
|
||||||
|
env: "dotenv",
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPECIAL_FILENAMES: Record<string, string> = {
|
||||||
|
Dockerfile: "dockerfile",
|
||||||
|
Makefile: "makefile",
|
||||||
|
Containerfile: "dockerfile",
|
||||||
|
Justfile: "makefile",
|
||||||
|
Rakefile: "ruby",
|
||||||
|
Gemfile: "ruby",
|
||||||
|
".env": "dotenv",
|
||||||
|
".env.local": "dotenv",
|
||||||
|
".env.example": "dotenv",
|
||||||
|
".eslintrc": "json",
|
||||||
|
".prettierrc": "json",
|
||||||
|
"tsconfig.json": "jsonc",
|
||||||
|
"jsconfig.json": "jsonc",
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLanguage(filepath: string): string | null {
|
||||||
|
const filename = filepath.split("/").pop() ?? ""
|
||||||
|
|
||||||
|
// Check special filenames first
|
||||||
|
if (SPECIAL_FILENAMES[filename]) return SPECIAL_FILENAMES[filename]
|
||||||
|
|
||||||
|
const ext = filename.includes(".") ? filename.split(".").pop()?.toLowerCase() : null
|
||||||
|
if (ext && EXT_TO_LANG[ext]) return EXT_TO_LANG[ext]
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarkdown(filepath: string): boolean {
|
||||||
|
const ext = filepath.split(".").pop()?.toLowerCase()
|
||||||
|
return ext === "md" || ext === "mdx"
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shiki singleton ── */
|
||||||
|
|
||||||
|
type ShikiHighlighter = {
|
||||||
|
codeToHtml: (code: string, options: { lang: string; theme: string }) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
let highlighterPromise: Promise<ShikiHighlighter> | null = null
|
||||||
|
|
||||||
|
async function getHighlighter(): Promise<ShikiHighlighter> {
|
||||||
|
if (!highlighterPromise) {
|
||||||
|
highlighterPromise = import("shiki").then((mod) =>
|
||||||
|
mod.createHighlighter({
|
||||||
|
themes: ["github-dark-default", "github-light-default"],
|
||||||
|
langs: [
|
||||||
|
"typescript", "tsx", "javascript", "jsx",
|
||||||
|
"json", "jsonc", "markdown", "mdx",
|
||||||
|
"css", "scss", "less", "html", "xml",
|
||||||
|
"yaml", "toml", "bash", "python", "ruby",
|
||||||
|
"rust", "go", "java", "kotlin", "swift",
|
||||||
|
"c", "cpp", "csharp", "php", "sql",
|
||||||
|
"graphql", "dockerfile", "makefile", "lua",
|
||||||
|
"diff", "ini", "dotenv",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).catch((err) => {
|
||||||
|
// Reset so the next call retries instead of returning a rejected promise forever
|
||||||
|
highlighterPromise = null
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return highlighterPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Code viewer (syntax highlighted) ── */
|
||||||
|
|
||||||
|
function CodeViewer({ content, filepath, shikiTheme = "github-dark-default" }: { content: string; filepath: string; shikiTheme?: string }) {
|
||||||
|
const [html, setHtml] = useState<string | null>(null)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const lang = detectLanguage(filepath)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
if (!lang) {
|
||||||
|
const readyTimer = window.setTimeout(() => {
|
||||||
|
setReady(true)
|
||||||
|
}, 0)
|
||||||
|
return () => window.clearTimeout(readyTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
getHighlighter().then((highlighter) => {
|
||||||
|
if (cancelled) return
|
||||||
|
try {
|
||||||
|
const highlighted = highlighter.codeToHtml(content, {
|
||||||
|
lang,
|
||||||
|
theme: shikiTheme,
|
||||||
|
})
|
||||||
|
setHtml(highlighted)
|
||||||
|
} catch {
|
||||||
|
// Language not loaded or unsupported — fall back to plain
|
||||||
|
setHtml(null)
|
||||||
|
}
|
||||||
|
setReady(true)
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) setReady(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [content, lang, shikiTheme])
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Highlighting…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (html) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="file-viewer-code overflow-x-auto text-sm leading-relaxed"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: plain text with line numbers
|
||||||
|
return <PlainViewer content={content} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Plain text viewer with line numbers ── */
|
||||||
|
|
||||||
|
function PlainViewer({ content }: { content: string }) {
|
||||||
|
const lines = useMemo(() => content.split("\n"), [content])
|
||||||
|
const gutterWidth = String(lines.length).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto text-sm leading-relaxed font-mono">
|
||||||
|
<table className="border-collapse">
|
||||||
|
<tbody>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<tr key={i} className="hover:bg-accent/20">
|
||||||
|
<td
|
||||||
|
className="select-none pr-4 text-right text-muted-foreground align-top"
|
||||||
|
style={{ minWidth: `${gutterWidth + 1}ch` }}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-pre text-muted-foreground">{line || " "}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Markdown viewer ── */
|
||||||
|
|
||||||
|
function MarkdownViewer({ content, filepath, shikiTheme = "github-dark-default" }: { content: string; filepath: string; shikiTheme?: string }) {
|
||||||
|
const [rendered, setRendered] = useState<React.ReactNode | null>(null)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
// Dynamic import to keep the main bundle lean
|
||||||
|
Promise.all([
|
||||||
|
import("react-markdown"),
|
||||||
|
import("remark-gfm"),
|
||||||
|
getHighlighter(),
|
||||||
|
]).then(([ReactMarkdownMod, remarkGfmMod, highlighter]) => {
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const ReactMarkdown = ReactMarkdownMod.default
|
||||||
|
const remarkGfm = remarkGfmMod.default
|
||||||
|
|
||||||
|
setRendered(
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
code({ className, children, ...props }) {
|
||||||
|
const match = /language-(\w+)/.exec(className || "")
|
||||||
|
const codeStr = String(children).replace(/\n$/, "")
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
const highlighted = highlighter.codeToHtml(codeStr, {
|
||||||
|
lang: match[1],
|
||||||
|
theme: shikiTheme,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="file-viewer-code my-3 rounded-md overflow-x-auto text-sm"
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlighted }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Fall through to default rendering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline code or unknown language
|
||||||
|
const isInline = !className && !String(children).includes("\n")
|
||||||
|
if (isInline) {
|
||||||
|
return (
|
||||||
|
<code className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono" {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre className="my-3 overflow-x-auto rounded-md bg-[#0d1117] p-4 text-sm">
|
||||||
|
<code>{children}</code>
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pre({ children }) {
|
||||||
|
// Unwrap <pre> since code blocks handle their own wrapper
|
||||||
|
return <>{children}</>
|
||||||
|
},
|
||||||
|
table({ children }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 overflow-x-auto">
|
||||||
|
<table className="min-w-full border-collapse border border-border text-sm">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
th({ children }) {
|
||||||
|
return (
|
||||||
|
<th className="border border-border bg-muted/50 px-3 py-2 text-left font-medium">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
td({ children }) {
|
||||||
|
return (
|
||||||
|
<td className="border border-border px-3 py-2">{children}</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
a({ href, children }) {
|
||||||
|
return (
|
||||||
|
<a href={href} className="text-info hover:underline" target="_blank" rel="noopener noreferrer">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
img({ src, alt }) {
|
||||||
|
return (
|
||||||
|
<span className="my-2 block rounded border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground italic">
|
||||||
|
🖼 {alt || (typeof src === "string" ? src : "") || "image"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>,
|
||||||
|
)
|
||||||
|
setReady(true)
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) setReady(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [content, filepath, shikiTheme])
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Rendering…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rendered) {
|
||||||
|
return <PlainViewer content={content} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="markdown-body">{rendered}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inline diff viewer — shows before/after with red/green line highlights ── */
|
||||||
|
|
||||||
|
function computeDiffLines(before: string, after: string): Array<{ type: "add" | "remove" | "context"; lineNum: number | null; text: string }> {
|
||||||
|
const oldLines = before.split("\n")
|
||||||
|
const newLines = after.split("\n")
|
||||||
|
const result: Array<{ type: "add" | "remove" | "context"; lineNum: number | null; text: string }> = []
|
||||||
|
|
||||||
|
// Simple LCS-based diff for inline display
|
||||||
|
const n = oldLines.length
|
||||||
|
const m = newLines.length
|
||||||
|
|
||||||
|
// For files that are too large, fall back to showing just additions/removals
|
||||||
|
if (n + m > 5000) {
|
||||||
|
oldLines.forEach((l, i) => result.push({ type: "remove", lineNum: i + 1, text: l }))
|
||||||
|
newLines.forEach((l, i) => result.push({ type: "add", lineNum: i + 1, text: l }))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build edit script using O(ND) algorithm (simplified Myers)
|
||||||
|
const max = n + m
|
||||||
|
const v = new Int32Array(2 * max + 1)
|
||||||
|
const trace: Int32Array[] = []
|
||||||
|
|
||||||
|
outer:
|
||||||
|
for (let d = 0; d <= max; d++) {
|
||||||
|
const vCopy = new Int32Array(v)
|
||||||
|
trace.push(vCopy)
|
||||||
|
for (let k = -d; k <= d; k += 2) {
|
||||||
|
let x: number
|
||||||
|
if (k === -d || (k !== d && v[k - 1 + max] < v[k + 1 + max])) {
|
||||||
|
x = v[k + 1 + max]
|
||||||
|
} else {
|
||||||
|
x = v[k - 1 + max] + 1
|
||||||
|
}
|
||||||
|
let y = x - k
|
||||||
|
while (x < n && y < m && oldLines[x] === newLines[y]) {
|
||||||
|
x++
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
v[k + max] = x
|
||||||
|
if (x >= n && y >= m) break outer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backtrack to produce diff
|
||||||
|
type Edit = { type: "add" | "remove" | "context"; oldIdx: number; newIdx: number }
|
||||||
|
const edits: Edit[] = []
|
||||||
|
let x = n, y = m
|
||||||
|
for (let d = trace.length - 1; d >= 0; d--) {
|
||||||
|
const vPrev = trace[d]
|
||||||
|
const k = x - y
|
||||||
|
let prevK: number
|
||||||
|
if (k === -d || (k !== d && vPrev[k - 1 + max] < vPrev[k + 1 + max])) {
|
||||||
|
prevK = k + 1
|
||||||
|
} else {
|
||||||
|
prevK = k - 1
|
||||||
|
}
|
||||||
|
const prevX = vPrev[prevK + max]
|
||||||
|
const prevY = prevX - prevK
|
||||||
|
|
||||||
|
// Diag moves = context lines
|
||||||
|
while (x > prevX && y > prevY) {
|
||||||
|
x--; y--
|
||||||
|
edits.push({ type: "context", oldIdx: x, newIdx: y })
|
||||||
|
}
|
||||||
|
if (d > 0) {
|
||||||
|
if (x === prevX) {
|
||||||
|
// Insert
|
||||||
|
y--
|
||||||
|
edits.push({ type: "add", oldIdx: x, newIdx: y })
|
||||||
|
} else {
|
||||||
|
// Delete
|
||||||
|
x--
|
||||||
|
edits.push({ type: "remove", oldIdx: x, newIdx: y })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
edits.reverse()
|
||||||
|
|
||||||
|
// Convert to output lines, showing only changed regions with ±3 lines of context
|
||||||
|
const CONTEXT = 3
|
||||||
|
const important = new Set<number>()
|
||||||
|
edits.forEach((e, i) => {
|
||||||
|
if (e.type !== "context") {
|
||||||
|
for (let j = Math.max(0, i - CONTEXT); j <= Math.min(edits.length - 1, i + CONTEXT); j++) {
|
||||||
|
important.add(j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let lastIncluded = -1
|
||||||
|
for (let i = 0; i < edits.length; i++) {
|
||||||
|
if (!important.has(i)) continue
|
||||||
|
if (lastIncluded >= 0 && i - lastIncluded > 1) {
|
||||||
|
result.push({ type: "context", lineNum: null, text: "···" })
|
||||||
|
}
|
||||||
|
const e = edits[i]
|
||||||
|
if (e.type === "context") {
|
||||||
|
result.push({ type: "context", lineNum: e.newIdx + 1, text: newLines[e.newIdx] })
|
||||||
|
} else if (e.type === "remove") {
|
||||||
|
result.push({ type: "remove", lineNum: e.oldIdx + 1, text: oldLines[e.oldIdx] })
|
||||||
|
} else {
|
||||||
|
result.push({ type: "add", lineNum: e.newIdx + 1, text: newLines[e.newIdx] })
|
||||||
|
}
|
||||||
|
lastIncluded = i
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineDiffViewer({ before, after, onDismiss }: { before: string; after: string; onDismiss?: () => void }) {
|
||||||
|
const lines = useMemo(() => computeDiffLines(before, after), [before, after])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto font-mono text-sm leading-relaxed">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<tbody>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
line.type === "add" && "bg-emerald-500/10",
|
||||||
|
line.type === "remove" && "bg-red-500/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="select-none w-[1ch] pl-2 pr-1 text-center align-top">
|
||||||
|
{line.type === "add" ? (
|
||||||
|
<span className="text-emerald-400/80">+</span>
|
||||||
|
) : line.type === "remove" ? (
|
||||||
|
<span className="text-red-400/80">−</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
"select-none pr-3 text-right align-top min-w-[3ch]",
|
||||||
|
line.type === "add" ? "text-emerald-400/40" :
|
||||||
|
line.type === "remove" ? "text-red-400/40" :
|
||||||
|
"text-muted-foreground/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{line.lineNum ?? ""}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
"whitespace-pre pr-4",
|
||||||
|
line.type === "add" && "text-emerald-300",
|
||||||
|
line.type === "remove" && "text-red-300 line-through decoration-red-400/30",
|
||||||
|
line.type === "context" && line.text === "···" && "text-muted-foreground/50 text-center italic",
|
||||||
|
line.type === "context" && line.text !== "···" && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{line.text || " "}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Read-only content renderer (shared between standalone and tab modes) ── */
|
||||||
|
|
||||||
|
function ReadOnlyContent({ content, filepath, fontSize, shikiTheme }: { content: string; filepath: string; fontSize?: number; shikiTheme?: string }) {
|
||||||
|
return (
|
||||||
|
<div style={fontSize ? { fontSize } : undefined}>
|
||||||
|
{isMarkdown(filepath) ? (
|
||||||
|
<MarkdownViewer content={content} filepath={filepath} shikiTheme={shikiTheme} />
|
||||||
|
) : (
|
||||||
|
<CodeViewer content={content} filepath={filepath} shikiTheme={shikiTheme} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Exported component ── */
|
||||||
|
|
||||||
|
interface FileContentViewerProps {
|
||||||
|
content: string
|
||||||
|
filepath: string
|
||||||
|
className?: string
|
||||||
|
/** Required for editing — the root context for the file */
|
||||||
|
root?: "gsd" | "project"
|
||||||
|
/** Required for editing — the relative path within the root */
|
||||||
|
path?: string
|
||||||
|
/** Required for editing — called with new content when the user saves */
|
||||||
|
onSave?: (newContent: string) => Promise<void>
|
||||||
|
/** When set, shows an inline diff overlay (before/after content) */
|
||||||
|
diff?: { before: string; after: string }
|
||||||
|
/** Called to dismiss the diff overlay */
|
||||||
|
onDismissDiff?: () => void
|
||||||
|
/** When true, MD files default to Edit tab so the raw changes are visible */
|
||||||
|
agentOpened?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileContentViewer({
|
||||||
|
content,
|
||||||
|
filepath,
|
||||||
|
className,
|
||||||
|
root,
|
||||||
|
path,
|
||||||
|
onSave,
|
||||||
|
diff,
|
||||||
|
onDismissDiff,
|
||||||
|
agentOpened,
|
||||||
|
}: FileContentViewerProps) {
|
||||||
|
const canEdit = root !== undefined && path !== undefined && onSave !== undefined
|
||||||
|
|
||||||
|
// ── Dirty state tracking ──
|
||||||
|
const [editContent, setEditContent] = useState(content)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Reset edit content when the source content changes (e.g. after save + re-fetch)
|
||||||
|
useEffect(() => {
|
||||||
|
setEditContent(content)
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
const isDirty = editContent !== content
|
||||||
|
|
||||||
|
const [fontSize] = useEditorFontSize()
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
const shikiTheme = resolvedTheme === "light" ? "github-light-default" : "github-dark-default"
|
||||||
|
const language = detectLanguage(filepath)
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!onSave || !isDirty || isSaving) return
|
||||||
|
setIsSaving(true)
|
||||||
|
setSaveError(null)
|
||||||
|
try {
|
||||||
|
await onSave(editContent)
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err instanceof Error ? err.message : "Failed to save")
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}, [onSave, isDirty, isSaving, editContent])
|
||||||
|
|
||||||
|
// ── Ctrl+S / Cmd+S keyboard shortcut ──
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handler)
|
||||||
|
return () => document.removeEventListener("keydown", handler)
|
||||||
|
}, [handleSave])
|
||||||
|
|
||||||
|
// ── Read-only mode (backward compatible) ──
|
||||||
|
if (!canEdit) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex-1 overflow-y-auto p-4", className)} style={{ fontSize }}>
|
||||||
|
<ReadOnlyContent content={content} filepath={filepath} fontSize={fontSize} shikiTheme={shikiTheme} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Diff overlay mode: agent just edited this file ──
|
||||||
|
if (diff) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-1 flex-col overflow-hidden min-h-0", className)}>
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-4 h-9">
|
||||||
|
<span className="text-sm font-medium font-mono truncate">{filepath}</span>
|
||||||
|
<span className="ml-2 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400 uppercase tracking-wide">
|
||||||
|
Changed
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onDismissDiff}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<InlineDiffViewer before={diff.before} after={diff.after} onDismiss={onDismissDiff} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editable mode: markdown keeps View/Edit tabs ──
|
||||||
|
if (isMarkdown(filepath)) {
|
||||||
|
return (
|
||||||
|
<Tabs key={agentOpened ? "agent-edit" : "normal"} defaultValue={agentOpened ? "edit" : "view"} className={cn("flex flex-1 flex-col overflow-hidden min-h-0", className)}>
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-4 h-9">
|
||||||
|
<span className="text-sm font-medium font-mono truncate mr-2">{filepath}</span>
|
||||||
|
<TabsList className="h-7 bg-transparent p-0 ml-auto">
|
||||||
|
<TabsTrigger
|
||||||
|
value="view"
|
||||||
|
className="h-6 rounded-md px-2 text-xs data-[state=active]:bg-muted"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="edit"
|
||||||
|
className="h-6 rounded-md px-2 text-xs data-[state=active]:bg-muted"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Save button */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{saveError && (
|
||||||
|
<span className="text-xs text-destructive max-w-[200px] truncate" title={saveError}>
|
||||||
|
{saveError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isDirty || isSaving}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
isDirty && !isSaving
|
||||||
|
? "bg-foreground text-background hover:bg-foreground/90"
|
||||||
|
: "bg-muted text-muted-foreground cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="view" className="flex-1 overflow-y-auto p-4 mt-0" style={{ fontSize }}>
|
||||||
|
<ReadOnlyContent content={content} filepath={filepath} fontSize={fontSize} shikiTheme={shikiTheme} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="edit" className="flex-1 overflow-hidden mt-0 min-h-0">
|
||||||
|
<CodeEditor
|
||||||
|
value={editContent}
|
||||||
|
onChange={setEditContent}
|
||||||
|
language={language}
|
||||||
|
fontSize={fontSize}
|
||||||
|
className="h-full border-0 rounded-none"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editable mode: non-markdown gets single CodeEditor view ──
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-1 flex-col overflow-hidden min-h-0", className)}>
|
||||||
|
{/* Header bar with filepath and save button */}
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-4 h-9">
|
||||||
|
<span className="text-sm font-medium font-mono truncate">{filepath}</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{saveError && (
|
||||||
|
<span className="text-xs text-destructive max-w-[200px] truncate" title={saveError}>
|
||||||
|
{saveError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isDirty || isSaving}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
isDirty && !isSaving
|
||||||
|
? "bg-foreground text-background hover:bg-foreground/90"
|
||||||
|
: "bg-muted text-muted-foreground cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* CodeEditor fills remaining space */}
|
||||||
|
<CodeEditor
|
||||||
|
value={editContent}
|
||||||
|
onChange={setEditContent}
|
||||||
|
language={language}
|
||||||
|
fontSize={fontSize}
|
||||||
|
className="flex-1 min-h-0 border-0 rounded-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1400
web/components/sf/files-view.tsx
Normal file
1400
web/components/sf/files-view.tsx
Normal file
File diff suppressed because it is too large
Load diff
332
web/components/sf/focused-panel.tsx
Normal file
332
web/components/sf/focused-panel.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { CheckSquare, MessageSquare, Send, TextCursorInput, Type } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
type PendingUiRequest,
|
||||||
|
useGSDWorkspaceActions,
|
||||||
|
useGSDWorkspaceState,
|
||||||
|
} from "@/lib/sf-workspace-store"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function methodIcon(method: PendingUiRequest["method"]) {
|
||||||
|
switch (method) {
|
||||||
|
case "select":
|
||||||
|
return <CheckSquare className="h-4 w-4" />
|
||||||
|
case "confirm":
|
||||||
|
return <MessageSquare className="h-4 w-4" />
|
||||||
|
case "input":
|
||||||
|
return <TextCursorInput className="h-4 w-4" />
|
||||||
|
case "editor":
|
||||||
|
return <Type className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodLabel(method: PendingUiRequest["method"]): string {
|
||||||
|
switch (method) {
|
||||||
|
case "select":
|
||||||
|
return "Selection"
|
||||||
|
case "confirm":
|
||||||
|
return "Confirmation"
|
||||||
|
case "input":
|
||||||
|
return "Input"
|
||||||
|
case "editor":
|
||||||
|
return "Editor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Renderers for each blocking UI request type ---
|
||||||
|
|
||||||
|
function SelectRenderer({
|
||||||
|
request,
|
||||||
|
onSubmit,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
request: Extract<PendingUiRequest, { method: "select" }>
|
||||||
|
onSubmit: (value: Record<string, unknown>) => void
|
||||||
|
disabled: boolean
|
||||||
|
}) {
|
||||||
|
const isMulti = Boolean(request.allowMultiple)
|
||||||
|
const [singleValue, setSingleValue] = useState("")
|
||||||
|
const [multiValues, setMultiValues] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (isMulti) {
|
||||||
|
onSubmit({ value: Array.from(multiValues) })
|
||||||
|
} else {
|
||||||
|
onSubmit({ value: singleValue })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = isMulti ? multiValues.size > 0 : singleValue !== ""
|
||||||
|
|
||||||
|
if (isMulti) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{request.options.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 transition-colors hover:bg-accent/40"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={multiValues.has(option)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const next = new Set(multiValues)
|
||||||
|
if (checked) {
|
||||||
|
next.add(option)
|
||||||
|
} else {
|
||||||
|
next.delete(option)
|
||||||
|
}
|
||||||
|
setMultiValues(next)
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{option}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSubmit} disabled={disabled || !canSubmit} className="w-full">
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Submit selection ({multiValues.size})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<RadioGroup value={singleValue} onValueChange={setSingleValue} disabled={disabled}>
|
||||||
|
{request.options.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 transition-colors hover:bg-accent/40"
|
||||||
|
>
|
||||||
|
<RadioGroupItem value={option} id={`select-${option}`} />
|
||||||
|
<Label htmlFor={`select-${option}`} className="cursor-pointer text-sm font-normal">
|
||||||
|
{option}
|
||||||
|
</Label>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
<Button onClick={handleSubmit} disabled={disabled || !canSubmit} className="w-full">
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmRenderer({
|
||||||
|
request,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
request: Extract<PendingUiRequest, { method: "confirm" }>
|
||||||
|
onSubmit: (value: Record<string, unknown>) => void
|
||||||
|
onCancel: () => void
|
||||||
|
disabled: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-border bg-background px-4 py-3 text-sm leading-relaxed">
|
||||||
|
{request.message}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button onClick={() => onSubmit({ value: true })} disabled={disabled} className="flex-1">
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onCancel} disabled={disabled} variant="outline" className="flex-1">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputRenderer({
|
||||||
|
request,
|
||||||
|
onSubmit,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
request: Extract<PendingUiRequest, { method: "input" }>
|
||||||
|
onSubmit: (value: Record<string, unknown>) => void
|
||||||
|
disabled: boolean
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState("")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (value.trim()) onSubmit({ value })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder={request.placeholder || "Enter a value"}
|
||||||
|
disabled={disabled}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={disabled || !value.trim()} className="w-full">
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorRenderer({
|
||||||
|
request,
|
||||||
|
onSubmit,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
request: Extract<PendingUiRequest, { method: "editor" }>
|
||||||
|
onSubmit: (value: Record<string, unknown>) => void
|
||||||
|
disabled: boolean
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState(request.prefill || "")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit({ value })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="min-h-[200px] font-mono text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={disabled} className="w-full">
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestBody({
|
||||||
|
request,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
request: PendingUiRequest
|
||||||
|
onSubmit: (value: Record<string, unknown>) => void
|
||||||
|
onCancel: () => void
|
||||||
|
disabled: boolean
|
||||||
|
}) {
|
||||||
|
switch (request.method) {
|
||||||
|
case "select":
|
||||||
|
return <SelectRenderer request={request} onSubmit={onSubmit} disabled={disabled} />
|
||||||
|
case "confirm":
|
||||||
|
return <ConfirmRenderer request={request} onSubmit={onSubmit} onCancel={onCancel} disabled={disabled} />
|
||||||
|
case "input":
|
||||||
|
return <InputRenderer request={request} onSubmit={onSubmit} disabled={disabled} />
|
||||||
|
case "editor":
|
||||||
|
return <EditorRenderer request={request} onSubmit={onSubmit} disabled={disabled} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FocusedPanel() {
|
||||||
|
const workspace = useGSDWorkspaceState()
|
||||||
|
const { respondToUiRequest, dismissUiRequest } = useGSDWorkspaceActions()
|
||||||
|
|
||||||
|
const pending = workspace.pendingUiRequests
|
||||||
|
const isOpen = pending.length > 0
|
||||||
|
const current = pending[0] ?? null
|
||||||
|
const isSubmitting = workspace.commandInFlight === "extension_ui_response"
|
||||||
|
|
||||||
|
const handleSubmit = (response: Record<string, unknown>) => {
|
||||||
|
if (!current) return
|
||||||
|
void respondToUiRequest(current.id, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
if (!current) return
|
||||||
|
void dismissUiRequest(current.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent the Sheet from closing via overlay click / escape while submitting
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (!open && !isSubmitting && current) {
|
||||||
|
handleDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
|
<SheetContent side="right" className="flex flex-col sm:max-w-md" data-testid="focused-panel">
|
||||||
|
{current && (
|
||||||
|
<>
|
||||||
|
<SheetHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{methodIcon(current.method)}
|
||||||
|
<SheetTitle>{current.title || methodLabel(current.method)}</SheetTitle>
|
||||||
|
</div>
|
||||||
|
<SheetDescription>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{methodLabel(current.method)} requested by the agent</span>
|
||||||
|
{pending.length > 1 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-semibold text-background",
|
||||||
|
)}
|
||||||
|
data-testid="focused-panel-queue-badge"
|
||||||
|
>
|
||||||
|
+{pending.length - 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-2">
|
||||||
|
<RequestBody
|
||||||
|
request={current}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleDismiss}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
web/components/sf/guided-dialog.tsx
Normal file
74
web/components/sf/guided-dialog.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { ChatPane } from "@/components/sf/chat-mode"
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GuidedDialogProps {
|
||||||
|
/** Whether the dialog is open */
|
||||||
|
open: boolean
|
||||||
|
/** Callback when open state changes (e.g. close button clicked) */
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
/** Detection kind for contextual title */
|
||||||
|
detectionKind?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getDialogTitle(detectionKind?: string): string {
|
||||||
|
switch (detectionKind) {
|
||||||
|
case "v1-legacy":
|
||||||
|
return "Migrating to GSD v2"
|
||||||
|
case "brownfield":
|
||||||
|
return "Mapping Your Project"
|
||||||
|
case "blank":
|
||||||
|
return "Setting Up Your Project"
|
||||||
|
default:
|
||||||
|
return "Getting Started"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen dialog that embeds ChatPane to render the bridge session
|
||||||
|
* response to an onboarding CTA command.
|
||||||
|
*
|
||||||
|
* The initial command dispatch is NOT handled here — it is managed by
|
||||||
|
* the parent (Dashboard) via a useEffect keyed on open + command.
|
||||||
|
*/
|
||||||
|
export function GuidedDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
detectionKind,
|
||||||
|
}: GuidedDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="sm:max-w-4xl h-[85vh] flex flex-col p-0 gap-0"
|
||||||
|
data-testid="guided-dialog"
|
||||||
|
>
|
||||||
|
<DialogHeader className="px-6 py-4 border-b border-border shrink-0">
|
||||||
|
<DialogTitle className="text-base font-semibold">
|
||||||
|
{getDialogTitle(detectionKind)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
Interactive guided setup — responses stream below as they are generated.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* ChatPane without onOpenAction hides the Discuss/Next/Auto action buttons */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<ChatPane className="h-full" />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
457
web/components/sf/knowledge-captures-panel.tsx
Normal file
457
web/components/sf/knowledge-captures-panel.tsx
Normal file
|
|
@ -0,0 +1,457 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
InboxIcon,
|
||||||
|
LoaderCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Zap,
|
||||||
|
Clock,
|
||||||
|
Tag,
|
||||||
|
FileText,
|
||||||
|
Lightbulb,
|
||||||
|
Repeat2,
|
||||||
|
StickyNote,
|
||||||
|
ArrowRightLeft,
|
||||||
|
CalendarClock,
|
||||||
|
ListTodo,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import type {
|
||||||
|
KnowledgeData,
|
||||||
|
KnowledgeEntry,
|
||||||
|
CapturesData,
|
||||||
|
CaptureEntry,
|
||||||
|
Classification,
|
||||||
|
} from "@/lib/knowledge-captures-types"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
useGSDWorkspaceActions,
|
||||||
|
useGSDWorkspaceState,
|
||||||
|
} from "@/lib/sf-workspace-store"
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SHARED HELPERS
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function PanelHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
status,
|
||||||
|
onRefresh,
|
||||||
|
refreshing,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
subtitle?: string | null
|
||||||
|
status?: React.ReactNode
|
||||||
|
onRefresh: () => void
|
||||||
|
refreshing: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 pb-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{title}</h3>
|
||||||
|
{status}
|
||||||
|
{subtitle && <span className="text-[11px] text-muted-foreground">{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing} className="h-7 gap-1.5 text-xs">
|
||||||
|
<RefreshCw className={cn("h-3 w-3", refreshing && "animate-spin")} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelError({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-xs text-destructive">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelLoading({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-6 text-xs text-muted-foreground">
|
||||||
|
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelEmpty({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/50 bg-card/50 px-4 py-5 text-center text-xs text-muted-foreground">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatPill({ label, value, variant }: { label: string; value: number | string; variant?: "default" | "error" | "warning" | "info" }) {
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs",
|
||||||
|
variant === "error" && "border-destructive/20 bg-destructive/5 text-destructive",
|
||||||
|
variant === "warning" && "border-warning/20 bg-warning/5 text-warning",
|
||||||
|
variant === "info" && "border-info/20 bg-info/5 text-info",
|
||||||
|
(!variant || variant === "default") && "border-border/50 bg-card/50 text-foreground/80",
|
||||||
|
)}>
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-medium tabular-nums">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// KNOWLEDGE TYPE STYLING
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function knowledgeTypeBadge(type: KnowledgeEntry["type"]) {
|
||||||
|
switch (type) {
|
||||||
|
case "rule":
|
||||||
|
return { label: "Rule", className: "border-violet-500/30 bg-violet-500/10 text-violet-400" }
|
||||||
|
case "pattern":
|
||||||
|
return { label: "Pattern", className: "border-info/30 bg-info/10 text-info" }
|
||||||
|
case "lesson":
|
||||||
|
return { label: "Lesson", className: "border-warning/30 bg-warning/10 text-warning" }
|
||||||
|
case "freeform":
|
||||||
|
return { label: "Freeform", className: "border-success/30 bg-success/10 text-success" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function KnowledgeTypeIcon({ type, className }: { type: KnowledgeEntry["type"]; className?: string }) {
|
||||||
|
const base = cn("h-3.5 w-3.5 shrink-0", className)
|
||||||
|
switch (type) {
|
||||||
|
case "rule":
|
||||||
|
return <Tag className={cn(base, "text-violet-400")} />
|
||||||
|
case "pattern":
|
||||||
|
return <Repeat2 className={cn(base, "text-info")} />
|
||||||
|
case "lesson":
|
||||||
|
return <Lightbulb className={cn(base, "text-warning")} />
|
||||||
|
case "freeform":
|
||||||
|
return <FileText className={cn(base, "text-success")} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// CAPTURE STATUS STYLING
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function captureStatusStyle(status: CaptureEntry["status"]) {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return { label: "Pending", className: "border-warning/30 bg-warning/10 text-warning" }
|
||||||
|
case "triaged":
|
||||||
|
return { label: "Triaged", className: "border-info/30 bg-info/10 text-info" }
|
||||||
|
case "resolved":
|
||||||
|
return { label: "Resolved", className: "border-success/30 bg-success/10 text-success" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classificationLabel(c: Classification): string {
|
||||||
|
switch (c) {
|
||||||
|
case "quick-task": return "Quick Task"
|
||||||
|
case "inject": return "Inject"
|
||||||
|
case "defer": return "Defer"
|
||||||
|
case "replan": return "Replan"
|
||||||
|
case "note": return "Note"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClassificationIcon({ classification, className }: { classification: Classification; className?: string }) {
|
||||||
|
const base = cn("h-3 w-3 shrink-0", className)
|
||||||
|
switch (classification) {
|
||||||
|
case "quick-task": return <Zap className={base} />
|
||||||
|
case "inject": return <ArrowRightLeft className={base} />
|
||||||
|
case "defer": return <CalendarClock className={base} />
|
||||||
|
case "replan": return <ListTodo className={base} />
|
||||||
|
case "note": return <StickyNote className={base} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLASSIFICATION_OPTIONS: Classification[] = ["quick-task", "inject", "defer", "replan", "note"]
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// KNOWLEDGE TAB CONTENT
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function KnowledgeEntryRow({ entry }: { entry: KnowledgeEntry }) {
|
||||||
|
const badge = knowledgeTypeBadge(entry.type)
|
||||||
|
return (
|
||||||
|
<div className="group rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 transition-colors hover:bg-card/50">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<KnowledgeTypeIcon type={entry.type} className="mt-0.5" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium text-foreground truncate">{entry.title}</span>
|
||||||
|
<Badge variant="outline" className={cn("text-[10px] px-1.5 py-0 h-4 shrink-0", badge.className)}>
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{entry.content && (
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground line-clamp-2 leading-relaxed">
|
||||||
|
{entry.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KnowledgeTabContent({
|
||||||
|
data,
|
||||||
|
phase,
|
||||||
|
error,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
data: KnowledgeData | null
|
||||||
|
phase: string
|
||||||
|
error: string | null
|
||||||
|
onRefresh: () => void
|
||||||
|
}) {
|
||||||
|
if (phase === "loading") return <PanelLoading label="Loading knowledge base…" />
|
||||||
|
if (phase === "error" && error) return <PanelError message={error} />
|
||||||
|
if (!data || data.entries.length === 0) return <PanelEmpty message="No knowledge entries found" />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<PanelHeader
|
||||||
|
title="Knowledge Base"
|
||||||
|
subtitle={`${data.entries.length} entries`}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
refreshing={phase === "loading"}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{data.entries.map((entry) => (
|
||||||
|
<KnowledgeEntryRow key={entry.id} entry={entry} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{data.lastModified && (
|
||||||
|
<p className="pt-2 text-[10px] text-muted-foreground">
|
||||||
|
Last modified: {new Date(data.lastModified).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// CAPTURES TAB CONTENT
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function CaptureEntryRow({
|
||||||
|
entry,
|
||||||
|
onResolve,
|
||||||
|
resolvePending,
|
||||||
|
}: {
|
||||||
|
entry: CaptureEntry
|
||||||
|
onResolve: (captureId: string, classification: Classification) => void
|
||||||
|
resolvePending: boolean
|
||||||
|
}) {
|
||||||
|
const status = captureStatusStyle(entry.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 transition-colors hover:bg-card/50">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<div className={cn(
|
||||||
|
"mt-1 h-2 w-2 shrink-0 rounded-full",
|
||||||
|
entry.status === "pending" && "bg-warning",
|
||||||
|
entry.status === "triaged" && "bg-info",
|
||||||
|
entry.status === "resolved" && "bg-success",
|
||||||
|
)} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-foreground">{entry.text}</span>
|
||||||
|
<Badge variant="outline" className={cn("text-[10px] px-1.5 py-0 h-4 shrink-0", status.className)}>
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
{entry.classification && (
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0 border-border/50 text-muted-foreground">
|
||||||
|
{classificationLabel(entry.classification)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entry.timestamp && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
|
<Clock className="h-2.5 w-2.5" />
|
||||||
|
{entry.timestamp}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entry.resolution && (
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground italic">{entry.resolution}</p>
|
||||||
|
)}
|
||||||
|
{entry.status === "pending" && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{CLASSIFICATION_OPTIONS.map((c) => (
|
||||||
|
<Button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={resolvePending}
|
||||||
|
onClick={() => onResolve(entry.id, c)}
|
||||||
|
className="h-6 gap-1 px-2 text-[10px] font-normal border-border/50 hover:bg-foreground/5"
|
||||||
|
>
|
||||||
|
<ClassificationIcon classification={c} />
|
||||||
|
{classificationLabel(c)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapturesTabContent({
|
||||||
|
data,
|
||||||
|
phase,
|
||||||
|
error,
|
||||||
|
resolvePending,
|
||||||
|
resolveError,
|
||||||
|
onRefresh,
|
||||||
|
onResolve,
|
||||||
|
}: {
|
||||||
|
data: CapturesData | null
|
||||||
|
phase: string
|
||||||
|
error: string | null
|
||||||
|
resolvePending: boolean
|
||||||
|
resolveError: string | null
|
||||||
|
onRefresh: () => void
|
||||||
|
onResolve: (captureId: string, classification: Classification) => void
|
||||||
|
}) {
|
||||||
|
if (phase === "loading") return <PanelLoading label="Loading captures…" />
|
||||||
|
if (phase === "error" && error) return <PanelError message={error} />
|
||||||
|
if (!data || data.entries.length === 0) return <PanelEmpty message="No captures found" />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<PanelHeader
|
||||||
|
title="Captures"
|
||||||
|
subtitle={`${data.entries.length} total`}
|
||||||
|
status={
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<StatPill label="Pending" value={data.pendingCount} variant={data.pendingCount > 0 ? "warning" : "default"} />
|
||||||
|
<StatPill label="Actionable" value={data.actionableCount} variant={data.actionableCount > 0 ? "info" : "default"} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
refreshing={phase === "loading"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{resolveError && (
|
||||||
|
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-[11px] text-destructive">
|
||||||
|
Resolve error: {resolveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{data.entries.map((entry) => (
|
||||||
|
<CaptureEntryRow
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
onResolve={onResolve}
|
||||||
|
resolvePending={resolvePending}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// MAIN PANEL COMPONENT
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
interface KnowledgeCapturesPanelProps {
|
||||||
|
initialTab: "knowledge" | "captures"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeCapturesPanel({ initialTab }: KnowledgeCapturesPanelProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<"knowledge" | "captures">(initialTab)
|
||||||
|
const workspace = useGSDWorkspaceState()
|
||||||
|
const { loadKnowledgeData, loadCapturesData, resolveCaptureAction } = useGSDWorkspaceActions()
|
||||||
|
|
||||||
|
const knowledgeCaptures = workspace.commandSurface.knowledgeCaptures
|
||||||
|
const knowledgeState = knowledgeCaptures.knowledge
|
||||||
|
const capturesState = knowledgeCaptures.captures
|
||||||
|
const resolveState = knowledgeCaptures.resolveRequest
|
||||||
|
|
||||||
|
const capturesData = capturesState.data as CapturesData | null
|
||||||
|
const pendingCount = capturesData?.pendingCount ?? 0
|
||||||
|
|
||||||
|
const handleResolve = (captureId: string, classification: Classification) => {
|
||||||
|
void resolveCaptureAction({
|
||||||
|
captureId,
|
||||||
|
classification,
|
||||||
|
resolution: "Manual browser triage",
|
||||||
|
rationale: "Triaged via web UI",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex items-center gap-0.5 border-b border-border/50 px-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("knowledge")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-all border-b-2 -mb-px",
|
||||||
|
activeTab === "knowledge"
|
||||||
|
? "border-foreground/60 text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BookOpen className="h-3.5 w-3.5" />
|
||||||
|
Knowledge
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("captures")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-all border-b-2 -mb-px",
|
||||||
|
activeTab === "captures"
|
||||||
|
? "border-foreground/60 text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<InboxIcon className="h-3.5 w-3.5" />
|
||||||
|
Captures
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<Badge variant="outline" className="ml-1 h-4 px-1.5 py-0 text-[10px] border-warning/30 bg-warning/10 text-warning">
|
||||||
|
{pendingCount} pending
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div className="p-4">
|
||||||
|
{activeTab === "knowledge" ? (
|
||||||
|
<KnowledgeTabContent
|
||||||
|
data={knowledgeState.data as KnowledgeData | null}
|
||||||
|
phase={knowledgeState.phase}
|
||||||
|
error={knowledgeState.error}
|
||||||
|
onRefresh={() => void loadKnowledgeData()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CapturesTabContent
|
||||||
|
data={capturesData}
|
||||||
|
phase={capturesState.phase}
|
||||||
|
error={capturesState.error}
|
||||||
|
resolvePending={resolveState.pending}
|
||||||
|
resolveError={resolveState.lastError}
|
||||||
|
onRefresh={() => void loadCapturesData()}
|
||||||
|
onResolve={handleResolve}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
198
web/components/sf/loading-skeletons.tsx
Normal file
198
web/components/sf/loading-skeletons.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// ─── Dashboard skeletons ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MetricCardSkeleton({ label, icon }: { label: string; icon: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-card p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{label}</p>
|
||||||
|
<Skeleton className="mt-2 h-7 w-24" />
|
||||||
|
<Skeleton className="mt-1.5 h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-md bg-accent p-2 text-muted-foreground">{icon}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CurrentUnitCardSkeleton({ icon }: { icon: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-card p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Current Unit</p>
|
||||||
|
<Skeleton className="mt-2 h-7 w-20" />
|
||||||
|
<Skeleton className="mt-1.5 h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-md bg-accent p-2 text-muted-foreground">{icon}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CurrentSliceCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-card">
|
||||||
|
<div className="border-b border-border px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold">Current Slice</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-4 w-4 shrink-0 rounded-full" />
|
||||||
|
<Skeleton className={cn("h-4", i === 1 ? "w-48" : i === 2 ? "w-40" : "w-36")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-card">
|
||||||
|
<div className="border-b border-border px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold">Session</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-3.5 w-3.5 rounded" />
|
||||||
|
<span className="text-muted-foreground">{i === 1 ? "Model" : i === 2 ? "Cost" : "Tokens"}</span>
|
||||||
|
</div>
|
||||||
|
<Skeleton className={cn("h-4", i === 1 ? "w-28" : "w-12")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecoveryCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-card">
|
||||||
|
<div className="border-b border-border px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold">Recovery Summary</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Skeleton className="h-4 w-44" />
|
||||||
|
<Skeleton className="h-3 w-full" />
|
||||||
|
<Skeleton className="h-3 w-3/4" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} className={cn("h-3", i % 2 === 0 ? "w-28" : "w-36")} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-9 w-36 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-card">
|
||||||
|
<div className="border-b border-border px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold">Recent Activity</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
|
||||||
|
<Skeleton className="h-3 w-16 shrink-0" />
|
||||||
|
<Skeleton className="h-1.5 w-1.5 shrink-0 rounded-full" />
|
||||||
|
<Skeleton className={cn("h-4 flex-1", i % 3 === 0 ? "max-w-xs" : i % 3 === 1 ? "max-w-sm" : "max-w-md")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardSkeletonProps {
|
||||||
|
icons: {
|
||||||
|
Activity: React.ReactNode
|
||||||
|
Clock: React.ReactNode
|
||||||
|
DollarSign: React.ReactNode
|
||||||
|
Zap: React.ReactNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardMetricsSkeleton({ icons }: DashboardSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
|
||||||
|
<CurrentUnitCardSkeleton icon={icons.Activity} />
|
||||||
|
<MetricCardSkeleton label="Elapsed Time" icon={icons.Clock} />
|
||||||
|
<MetricCardSkeleton label="Total Cost" icon={icons.DollarSign} />
|
||||||
|
<MetricCardSkeleton label="Tokens Used" icon={icons.Zap} />
|
||||||
|
<MetricCardSkeleton label="Progress" icon={icons.Activity} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sidebar skeletons ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Only the data-dependent portion of the sidebar content panel */
|
||||||
|
export function SidebarDataSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Project path */}
|
||||||
|
<Skeleton className="mt-2 h-3 w-36" />
|
||||||
|
|
||||||
|
{/* Scope section */}
|
||||||
|
<div className="border-b border-border px-3 py-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Active scope</p>
|
||||||
|
<Skeleton className="h-3.5 w-32" />
|
||||||
|
<Skeleton className="h-2.5 w-28" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestones list */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
|
<div className="px-2 py-1.5">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Milestones
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 px-1">
|
||||||
|
{[1, 2].map((m) => (
|
||||||
|
<div key={m}>
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||||
|
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
||||||
|
<Skeleton className="h-4 w-4 shrink-0 rounded-full" />
|
||||||
|
<Skeleton className={cn("h-4", m === 1 ? "w-40" : "w-32")} />
|
||||||
|
</div>
|
||||||
|
{m === 1 && (
|
||||||
|
<div className="ml-4 space-y-0.5">
|
||||||
|
{[1, 2, 3].map((s) => (
|
||||||
|
<div key={s} className="flex items-center gap-1.5 px-2 py-1.5">
|
||||||
|
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
||||||
|
<Skeleton className="h-4 w-4 shrink-0 rounded-full" />
|
||||||
|
<Skeleton className={cn("h-3.5", s === 1 ? "w-32" : s === 2 ? "w-28" : "w-24")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Status bar value skeletons ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function StatusBarValueSkeleton({ width = "w-16" }: { width?: string }) {
|
||||||
|
return <Skeleton className={cn("h-3 inline-block", width)} />
|
||||||
|
}
|
||||||
394
web/components/sf/main-session-terminal.tsx
Normal file
394
web/components/sf/main-session-terminal.tsx
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Loader2, ImagePlus } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { validateImageFile } from "@/lib/image-utils"
|
||||||
|
import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url"
|
||||||
|
import { authFetch, appendAuthParam } from "@/lib/auth"
|
||||||
|
import { getXtermOptions, getXtermTheme } from "@/lib/xterm-theme"
|
||||||
|
import "@xterm/xterm/css/xterm.css"
|
||||||
|
|
||||||
|
type XTerminal = import("@xterm/xterm").Terminal
|
||||||
|
type XFitAddon = import("@xterm/addon-fit").FitAddon
|
||||||
|
|
||||||
|
interface MainSessionTerminalProps {
|
||||||
|
className?: string
|
||||||
|
fontSize?: number
|
||||||
|
projectCwd?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_INITIAL_ATTACH_WIDTH = 180
|
||||||
|
const MIN_INITIAL_ATTACH_HEIGHT = 120
|
||||||
|
const MIN_INITIAL_ATTACH_COLS = 20
|
||||||
|
const MIN_INITIAL_ATTACH_ROWS = 8
|
||||||
|
|
||||||
|
function getAttachableTerminalSize(container: HTMLDivElement | null, terminal: XTerminal | null): { cols: number; rows: number } | null {
|
||||||
|
if (!container || !terminal) return null
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect()
|
||||||
|
if (rect.width < MIN_INITIAL_ATTACH_WIDTH || rect.height < MIN_INITIAL_ATTACH_HEIGHT) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminal.cols < MIN_INITIAL_ATTACH_COLS || terminal.rows < MIN_INITIAL_ATTACH_ROWS) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cols: terminal.cols, rows: terminal.rows }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleTerminalLayout(
|
||||||
|
container: HTMLDivElement | null,
|
||||||
|
terminal: XTerminal | null,
|
||||||
|
fitAddon: XFitAddon | null,
|
||||||
|
isDisposed: () => boolean,
|
||||||
|
): Promise<{ cols: number; rows: number } | null> {
|
||||||
|
if (typeof document !== "undefined" && "fonts" in document) {
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
document.fonts.ready,
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
|
||||||
|
])
|
||||||
|
} catch {
|
||||||
|
// Ignore font loading failures and fall through to repeated fit attempts.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 12; attempt++) {
|
||||||
|
if (isDisposed()) return null
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
requestAnimationFrame(() => resolve())
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isDisposed()) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
fitAddon?.fit()
|
||||||
|
} catch {
|
||||||
|
// Hidden or detached.
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = getAttachableTerminalSize(container, terminal)
|
||||||
|
if (size) {
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
}
|
||||||
|
|
||||||
|
return getAttachableTerminalSize(container, terminal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MainSessionTerminal({ className, fontSize, projectCwd }: MainSessionTerminalProps) {
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
const isDark = resolvedTheme !== "light"
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const termRef = useRef<XTerminal | null>(null)
|
||||||
|
const fitAddonRef = useRef<XFitAddon | null>(null)
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null)
|
||||||
|
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const inputQueueRef = useRef<string[]>([])
|
||||||
|
const flushingRef = useRef(false)
|
||||||
|
const [connectionState, setConnectionState] = useState<"connecting" | "connected" | "error">("connecting")
|
||||||
|
const [hasOutput, setHasOutput] = useState(false)
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
|
||||||
|
const flushInputQueue = useCallback(async () => {
|
||||||
|
if (flushingRef.current) return
|
||||||
|
flushingRef.current = true
|
||||||
|
while (inputQueueRef.current.length > 0) {
|
||||||
|
const data = inputQueueRef.current.shift()!
|
||||||
|
try {
|
||||||
|
await authFetch(buildProjectPath("/api/bridge-terminal/input", projectCwd), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ data }),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
inputQueueRef.current.unshift(data)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushingRef.current = false
|
||||||
|
}, [projectCwd])
|
||||||
|
|
||||||
|
const sendInput = useCallback((data: string) => {
|
||||||
|
inputQueueRef.current.push(data)
|
||||||
|
void flushInputQueue()
|
||||||
|
}, [flushInputQueue])
|
||||||
|
|
||||||
|
const sendResize = useCallback((cols: number, rows: number) => {
|
||||||
|
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
|
||||||
|
resizeTimeoutRef.current = setTimeout(() => {
|
||||||
|
void authFetch(buildProjectPath("/api/bridge-terminal/resize", projectCwd), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ cols, rows }),
|
||||||
|
})
|
||||||
|
}, 75)
|
||||||
|
}, [projectCwd])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (termRef.current) {
|
||||||
|
termRef.current.options.theme = getXtermTheme(isDark)
|
||||||
|
}
|
||||||
|
}, [isDark])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!termRef.current) return
|
||||||
|
termRef.current.options.fontSize = fontSize ?? 13
|
||||||
|
try {
|
||||||
|
fitAddonRef.current?.fit()
|
||||||
|
sendResize(termRef.current.cols, termRef.current.rows)
|
||||||
|
} catch {
|
||||||
|
// Hidden or not mounted yet.
|
||||||
|
}
|
||||||
|
}, [fontSize, sendResize])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
let disposed = false
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
let terminal: XTerminal | null = null
|
||||||
|
let fitAddon: XFitAddon | null = null
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
||||||
|
import("@xterm/xterm"),
|
||||||
|
import("@xterm/addon-fit"),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (disposed) return
|
||||||
|
|
||||||
|
terminal = new Terminal(getXtermOptions(isDark, fontSize))
|
||||||
|
fitAddon = new FitAddon()
|
||||||
|
terminal.loadAddon(fitAddon)
|
||||||
|
terminal.open(containerRef.current!)
|
||||||
|
|
||||||
|
termRef.current = terminal
|
||||||
|
fitAddonRef.current = fitAddon
|
||||||
|
|
||||||
|
const initialSize = await settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed)
|
||||||
|
if (disposed) return
|
||||||
|
|
||||||
|
terminal.onData((data) => {
|
||||||
|
sendInput(data)
|
||||||
|
})
|
||||||
|
terminal.onBinary((data) => {
|
||||||
|
sendInput(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
const connectStream = (preferredSize: { cols: number; rows: number } | null) => {
|
||||||
|
const streamUrl = buildProjectAbsoluteUrl(
|
||||||
|
"/api/bridge-terminal/stream",
|
||||||
|
window.location.origin,
|
||||||
|
projectCwd,
|
||||||
|
)
|
||||||
|
if (preferredSize) {
|
||||||
|
streamUrl.searchParams.set("cols", String(preferredSize.cols))
|
||||||
|
streamUrl.searchParams.set("rows", String(preferredSize.rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = new EventSource(appendAuthParam(streamUrl.toString()))
|
||||||
|
eventSourceRef.current = es
|
||||||
|
setConnectionState((current) => (current === "connected" ? current : "connecting"))
|
||||||
|
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data) as { type: string; data?: string }
|
||||||
|
if (message.type === "connected") {
|
||||||
|
setConnectionState("connected")
|
||||||
|
void settleTerminalLayout(containerRef.current, termRef.current, fitAddonRef.current, () => disposed).then((size) => {
|
||||||
|
if (!size) return
|
||||||
|
sendResize(size.cols, size.rows)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "output" && typeof message.data === "string") {
|
||||||
|
termRef.current?.write(message.data)
|
||||||
|
setHasOutput(true)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setConnectionState("error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
setConnectionState("error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectStream(initialSize)
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (disposed) return
|
||||||
|
try {
|
||||||
|
fitAddon?.fit()
|
||||||
|
if (terminal) {
|
||||||
|
sendResize(terminal.cols, terminal.rows)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Hidden or detached.
|
||||||
|
}
|
||||||
|
})
|
||||||
|
resizeObserver.observe(containerRef.current!)
|
||||||
|
}
|
||||||
|
|
||||||
|
void init()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
|
||||||
|
eventSourceRef.current?.close()
|
||||||
|
eventSourceRef.current = null
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
terminal?.dispose()
|
||||||
|
termRef.current = null
|
||||||
|
fitAddonRef.current = null
|
||||||
|
}
|
||||||
|
}, [fontSize, isDark, projectCwd, sendInput, sendResize])
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
termRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── Shift+Enter → newline (native DOM, capture phase) ────────────────────
|
||||||
|
// xterm.js sends \r for both Enter and Shift+Enter. The pi TUI editor
|
||||||
|
// recognizes \n (LF) as "insert newline". Capture-phase keydown intercepts
|
||||||
|
// before xterm's internal textarea processes the event.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = wrapperRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
sendInput("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener("keydown", onKeyDown, true)
|
||||||
|
return () => el.removeEventListener("keydown", onKeyDown, true)
|
||||||
|
}, [sendInput])
|
||||||
|
|
||||||
|
// ── Drag-and-drop image upload (native DOM, capture phase) ──────────────
|
||||||
|
// React synthetic events don't reliably fire through xterm's internal DOM.
|
||||||
|
// Native capture-phase listeners intercept before xterm can swallow them —
|
||||||
|
// same pattern used for paste in ShellTerminal.
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = wrapperRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
let counter = 0
|
||||||
|
|
||||||
|
const onDragEnter = (e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
counter += 1
|
||||||
|
if (counter === 1) setIsDragOver(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragOver = (e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragLeave = (e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
counter -= 1
|
||||||
|
if (counter <= 0) {
|
||||||
|
counter = 0
|
||||||
|
setIsDragOver(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
counter = 0
|
||||||
|
setIsDragOver(false)
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer?.files ?? [])
|
||||||
|
const imageFile = files.find((f) => f.type.startsWith("image/"))
|
||||||
|
if (!imageFile) return
|
||||||
|
|
||||||
|
const validation = validateImageFile(imageFile)
|
||||||
|
if (!validation.valid) {
|
||||||
|
console.warn("[main-terminal-upload] validation failed:", validation.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", imageFile)
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(buildProjectPath("/api/terminal/upload", projectCwd), {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
const data = (await res.json()) as { ok?: boolean; path?: string; error?: string }
|
||||||
|
if (!res.ok || !data.path) {
|
||||||
|
console.error("[main-terminal-upload] upload failed:", data.error ?? `HTTP ${res.status}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log("[main-terminal-upload] injecting path:", data.path)
|
||||||
|
sendInput(`@${data.path} `)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[main-terminal-upload] upload request failed:", err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener("dragenter", onDragEnter, true)
|
||||||
|
el.addEventListener("dragover", onDragOver, true)
|
||||||
|
el.addEventListener("dragleave", onDragLeave, true)
|
||||||
|
el.addEventListener("drop", onDrop, true)
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener("dragenter", onDragEnter, true)
|
||||||
|
el.removeEventListener("dragover", onDragOver, true)
|
||||||
|
el.removeEventListener("dragleave", onDragLeave, true)
|
||||||
|
el.removeEventListener("drop", onDrop, true)
|
||||||
|
}
|
||||||
|
}, [projectCwd, sendInput])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => termRef.current?.focus(), 80)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className={cn("relative h-full w-full bg-terminal", className)}
|
||||||
|
onClick={handleClick}
|
||||||
|
data-testid="main-session-native-terminal"
|
||||||
|
>
|
||||||
|
{!hasOutput && (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-terminal">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{connectionState === "error" ? "Reconnecting main session terminal…" : "Connecting to main session…"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Drop overlay */}
|
||||||
|
{isDragOver && (
|
||||||
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-background backdrop-blur-sm border-2 border-dashed border-primary rounded-md pointer-events-none">
|
||||||
|
<ImagePlus className="h-8 w-8 text-primary" />
|
||||||
|
<span className="text-sm font-medium text-primary">Drop image here</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={containerRef} className="h-full w-full" style={{ padding: "8px 4px 4px 8px" }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
303
web/components/sf/onboarding-gate.tsx
Normal file
303
web/components/sf/onboarding-gate.tsx
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import {
|
||||||
|
type WorkspaceOnboardingProviderState,
|
||||||
|
useGSDWorkspaceActions,
|
||||||
|
useGSDWorkspaceState,
|
||||||
|
} from "@/lib/sf-workspace-store"
|
||||||
|
import { useDevOverrides } from "@/lib/dev-overrides"
|
||||||
|
import { useUserMode, type UserMode } from "@/lib/use-user-mode"
|
||||||
|
import { navigateToGSDView } from "@/lib/workflow-action-execution"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { StepWelcome } from "./onboarding/step-welcome"
|
||||||
|
import { StepMode } from "./onboarding/step-mode"
|
||||||
|
import { StepProvider } from "./onboarding/step-provider"
|
||||||
|
import { StepAuthenticate } from "./onboarding/step-authenticate"
|
||||||
|
import { StepDevRoot } from "./onboarding/step-dev-root"
|
||||||
|
import { StepOptional } from "./onboarding/step-optional"
|
||||||
|
import { StepRemote } from "./onboarding/step-remote"
|
||||||
|
import { StepReady } from "./onboarding/step-ready"
|
||||||
|
import { StepProject } from "./onboarding/step-project"
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const WIZARD_STEPS = [
|
||||||
|
{ id: "welcome", label: "Welcome" },
|
||||||
|
{ id: "mode", label: "Mode" },
|
||||||
|
{ id: "provider", label: "Provider" },
|
||||||
|
{ id: "authenticate", label: "Auth" },
|
||||||
|
{ id: "devRoot", label: "Root" },
|
||||||
|
{ id: "optional", label: "Extras" },
|
||||||
|
{ id: "remote", label: "Remote" },
|
||||||
|
{ id: "ready", label: "Ready" },
|
||||||
|
{ id: "project", label: "Project" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const TOTAL_STEPS = WIZARD_STEPS.length
|
||||||
|
const EMPTY_PROVIDERS: WorkspaceOnboardingProviderState[] = []
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function chooseDefaultProvider(providers: WorkspaceOnboardingProviderState[]): string | null {
|
||||||
|
const unresolvedRecommended = providers.find((p) => !p.configured && p.recommended)
|
||||||
|
if (unresolvedRecommended) return unresolvedRecommended.id
|
||||||
|
const unresolved = providers.find((p) => !p.configured)
|
||||||
|
if (unresolved) return unresolved.id
|
||||||
|
return providers[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide animation
|
||||||
|
const slideVariants = {
|
||||||
|
enter: (dir: number) => ({ x: dir > 0 ? 50 : -50, opacity: 0 }),
|
||||||
|
center: { x: 0, opacity: 1 },
|
||||||
|
exit: (dir: number) => ({ x: dir < 0 ? 50 : -50, opacity: 0 }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step indicator (centered row of dots with labels) ──────────────
|
||||||
|
|
||||||
|
function StepIndicator({ current, total }: { current: number; total: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: total }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full transition-all duration-300",
|
||||||
|
i === current
|
||||||
|
? "h-1.5 w-5 bg-foreground"
|
||||||
|
: i < current
|
||||||
|
? "h-1.5 w-1.5 bg-foreground/40"
|
||||||
|
: "h-1.5 w-1.5 bg-foreground/10",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function OnboardingGate() {
|
||||||
|
const workspace = useGSDWorkspaceState()
|
||||||
|
const {
|
||||||
|
refreshOnboarding,
|
||||||
|
saveApiKey,
|
||||||
|
startProviderFlow,
|
||||||
|
submitProviderFlowInput,
|
||||||
|
cancelProviderFlow,
|
||||||
|
refreshBoot,
|
||||||
|
} = useGSDWorkspaceActions()
|
||||||
|
const devOverrides = useDevOverrides()
|
||||||
|
|
||||||
|
const onboarding = workspace.boot?.onboarding
|
||||||
|
const forceVisible = devOverrides.isActive("forceOnboarding")
|
||||||
|
const isBusy = workspace.onboardingRequestState !== "idle"
|
||||||
|
|
||||||
|
// ─── Wizard state ───
|
||||||
|
const [stepIndex, setStepIndex] = useState(0)
|
||||||
|
const [direction, setDirection] = useState(0)
|
||||||
|
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null)
|
||||||
|
const [dismissedAfterSuccess, setDismissedAfterSuccess] = useState(false)
|
||||||
|
const [userMode, setUserMode] = useUserMode()
|
||||||
|
const [selectedMode, setSelectedMode] = useState<UserMode | null>(userMode)
|
||||||
|
|
||||||
|
const providers = onboarding?.required.providers ?? EMPTY_PROVIDERS
|
||||||
|
const effectiveSelectedProviderId = useMemo(() => {
|
||||||
|
if (onboarding?.activeFlow?.providerId) return onboarding.activeFlow.providerId
|
||||||
|
if (selectedProviderId && providers.some((p) => p.id === selectedProviderId)) return selectedProviderId
|
||||||
|
return chooseDefaultProvider(providers)
|
||||||
|
}, [onboarding?.activeFlow?.providerId, providers, selectedProviderId])
|
||||||
|
const shouldHideAfterSuccess = dismissedAfterSuccess && !onboarding?.locked && !isBusy
|
||||||
|
|
||||||
|
// Track whether auth was locked when the user arrived at step 3.
|
||||||
|
// Auto-advance only fires when auth transitions from locked → unlocked
|
||||||
|
// while the user is on the auth step — not when navigating back or
|
||||||
|
// when the provider was already configured.
|
||||||
|
const [authWasLockedOnArrival, setAuthWasLockedOnArrival] = useState(false)
|
||||||
|
|
||||||
|
const goTo = useCallback(
|
||||||
|
(target: number) => {
|
||||||
|
// When arriving at auth step, snapshot the locked state
|
||||||
|
if (target === 3 && onboarding?.locked) {
|
||||||
|
setAuthWasLockedOnArrival(true)
|
||||||
|
} else if (target === 3 && !onboarding?.locked) {
|
||||||
|
// Already unlocked — don't set the flag (prevents auto-advance)
|
||||||
|
setAuthWasLockedOnArrival(false)
|
||||||
|
}
|
||||||
|
setDirection(target > stepIndex ? 1 : -1)
|
||||||
|
setStepIndex(target)
|
||||||
|
},
|
||||||
|
[stepIndex, onboarding?.locked],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auto-advance past auth only when it just succeeded during this visit
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onboarding) return
|
||||||
|
if (stepIndex !== 3) return
|
||||||
|
if (!authWasLockedOnArrival) return
|
||||||
|
const isUnlocked = !onboarding.locked
|
||||||
|
const bridgeDone = onboarding.bridgeAuthRefresh.phase === "succeeded" || onboarding.bridgeAuthRefresh.phase === "idle"
|
||||||
|
if (!isUnlocked || !bridgeDone) return
|
||||||
|
const t = window.setTimeout(() => goTo(4), 0)
|
||||||
|
return () => window.clearTimeout(t)
|
||||||
|
}, [onboarding, goTo, stepIndex, authWasLockedOnArrival])
|
||||||
|
|
||||||
|
const selectedProvider = useMemo(() => {
|
||||||
|
return providers.find((p) => p.id === effectiveSelectedProviderId) ?? null
|
||||||
|
}, [effectiveSelectedProviderId, providers])
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Gate check ───
|
||||||
|
if (!onboarding) return null
|
||||||
|
const onboardingSettled =
|
||||||
|
!onboarding.locked ||
|
||||||
|
(onboarding.lastValidation?.status === "succeeded" &&
|
||||||
|
(onboarding.bridgeAuthRefresh.phase === "succeeded" || onboarding.bridgeAuthRefresh.phase === "idle"))
|
||||||
|
if (!forceVisible && (onboardingSettled || shouldHideAfterSuccess)) return null
|
||||||
|
|
||||||
|
const stepLabel = WIZARD_STEPS[stepIndex]?.label ?? ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto absolute inset-0 z-30 flex flex-col bg-background" data-testid="onboarding-gate">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="relative z-10 flex h-12 shrink-0 items-center justify-between px-5 md:px-8">
|
||||||
|
{/* Left — logo */}
|
||||||
|
<div className="flex w-24 items-center gap-2">
|
||||||
|
<Image src="/logo-white.svg" alt="GSD" width={57} height={16} className="hidden h-4 w-auto dark:block" />
|
||||||
|
<Image src="/logo-black.svg" alt="GSD" width={57} height={16} className="h-4 w-auto dark:hidden" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center — step indicator */}
|
||||||
|
<div className="absolute inset-x-0 flex justify-center pointer-events-none">
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<StepIndicator current={stepIndex} total={TOTAL_STEPS} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right — step label */}
|
||||||
|
<div className="flex w-24 justify-end">
|
||||||
|
<span className="text-xs text-muted-foreground">{stepLabel}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Thin progress — hidden when not needed */}
|
||||||
|
|
||||||
|
{/* Content — full remaining height, scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="mx-auto flex min-h-full w-full max-w-2xl flex-col justify-center px-5 py-10 md:px-8 md:py-16">
|
||||||
|
<AnimatePresence mode="wait" custom={direction}>
|
||||||
|
<motion.div
|
||||||
|
key={stepIndex}
|
||||||
|
custom={direction}
|
||||||
|
variants={slideVariants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ type: "spring", stiffness: 400, damping: 35, opacity: { duration: 0.15 } }}
|
||||||
|
>
|
||||||
|
{stepIndex === 0 && <StepWelcome onNext={() => goTo(1)} />}
|
||||||
|
|
||||||
|
{stepIndex === 1 && (
|
||||||
|
<StepMode
|
||||||
|
selected={selectedMode}
|
||||||
|
onSelect={(mode) => { setSelectedMode(mode); setUserMode(mode) }}
|
||||||
|
onNext={() => goTo(2)}
|
||||||
|
onBack={() => goTo(0)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stepIndex === 2 && (
|
||||||
|
<StepProvider
|
||||||
|
providers={onboarding.required.providers}
|
||||||
|
selectedId={effectiveSelectedProviderId}
|
||||||
|
onSelect={(id) => {
|
||||||
|
setSelectedProviderId(id)
|
||||||
|
goTo(3)
|
||||||
|
}}
|
||||||
|
onNext={() => goTo(4)}
|
||||||
|
onBack={() => goTo(1)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stepIndex === 3 && selectedProvider && (
|
||||||
|
<StepAuthenticate
|
||||||
|
provider={selectedProvider}
|
||||||
|
activeFlow={onboarding.activeFlow}
|
||||||
|
lastValidation={onboarding.lastValidation}
|
||||||
|
requestState={workspace.onboardingRequestState}
|
||||||
|
requestProviderId={workspace.onboardingRequestProviderId}
|
||||||
|
onSaveApiKey={async (pid, key) => {
|
||||||
|
const next = await saveApiKey(pid, key)
|
||||||
|
const settled = Boolean(
|
||||||
|
next && !next.locked &&
|
||||||
|
(next.bridgeAuthRefresh.phase === "succeeded" || next.bridgeAuthRefresh.phase === "idle"),
|
||||||
|
)
|
||||||
|
if (settled) { setDismissedAfterSuccess(true); void refreshBoot() }
|
||||||
|
return next
|
||||||
|
}}
|
||||||
|
onStartFlow={(pid) => void startProviderFlow(pid)}
|
||||||
|
onSubmitFlowInput={(fid, input) => void submitProviderFlowInput(fid, input)}
|
||||||
|
onCancelFlow={(fid) => void cancelProviderFlow(fid)}
|
||||||
|
onBack={() => goTo(2)}
|
||||||
|
onNext={() => goTo(2)}
|
||||||
|
bridgeRefreshPhase={onboarding.bridgeAuthRefresh.phase}
|
||||||
|
bridgeRefreshError={onboarding.bridgeAuthRefresh.error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stepIndex === 4 && <StepDevRoot onBack={() => goTo(2)} onNext={() => goTo(5)} />}
|
||||||
|
|
||||||
|
{stepIndex === 5 && (
|
||||||
|
<StepOptional
|
||||||
|
sections={onboarding.optional.sections}
|
||||||
|
onBack={() => goTo(4)}
|
||||||
|
onNext={() => goTo(6)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stepIndex === 6 && (
|
||||||
|
<StepRemote
|
||||||
|
onBack={() => goTo(5)}
|
||||||
|
onNext={() => goTo(7)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stepIndex === 7 && (
|
||||||
|
<StepReady
|
||||||
|
providerLabel={
|
||||||
|
onboarding.lastValidation?.providerId
|
||||||
|
? onboarding.required.providers.find((p) => p.id === onboarding.lastValidation?.providerId)?.label ?? "Provider"
|
||||||
|
: "Provider"
|
||||||
|
}
|
||||||
|
onFinish={() => goTo(8)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stepIndex === 8 && (
|
||||||
|
<StepProject
|
||||||
|
onBack={() => goTo(7)}
|
||||||
|
onBeforeSwitch={() => {
|
||||||
|
// Disarm the gate BEFORE switchProject triggers a store remount
|
||||||
|
if (devOverrides.isActive("forceOnboarding")) {
|
||||||
|
devOverrides.toggle("forceOnboarding")
|
||||||
|
}
|
||||||
|
setDismissedAfterSuccess(true)
|
||||||
|
}}
|
||||||
|
onFinish={() => {
|
||||||
|
const mode = selectedMode ?? userMode
|
||||||
|
navigateToGSDView("dashboard")
|
||||||
|
void refreshBoot()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
496
web/components/sf/onboarding/step-authenticate.tsx
Normal file
496
web/components/sf/onboarding/step-authenticate.tsx
Normal file
|
|
@ -0,0 +1,496 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { motion, AnimatePresence } from "motion/react"
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUpRight,
|
||||||
|
CheckCircle2,
|
||||||
|
ClipboardCopy,
|
||||||
|
ExternalLink,
|
||||||
|
KeyRound,
|
||||||
|
LoaderCircle,
|
||||||
|
RotateCcw,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import type {
|
||||||
|
WorkspaceOnboardingFlowState,
|
||||||
|
WorkspaceOnboardingProviderState,
|
||||||
|
WorkspaceOnboardingRequestState,
|
||||||
|
WorkspaceOnboardingState,
|
||||||
|
WorkspaceOnboardingValidationResult,
|
||||||
|
} from "@/lib/sf-workspace-store"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// ─── Error parsing ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseValidationError(raw: string | null | undefined): { title: string; detail: string | null } {
|
||||||
|
if (!raw) return { title: "Validation failed", detail: null }
|
||||||
|
|
||||||
|
const jsonInStatusMatch = raw.match(/^\d{3}\s+[^:]+:\s*(.+)$/s)
|
||||||
|
const jsonCandidate = jsonInStatusMatch?.[1] ?? raw
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonCandidate)
|
||||||
|
if (typeof parsed === "object" && parsed !== null) {
|
||||||
|
const message = parsed.error_details?.message ?? parsed.error?.message ?? parsed.message ?? parsed.error ?? null
|
||||||
|
if (typeof message === "string" && message.length > 0) {
|
||||||
|
if (/subscription.*(ended|expired|cancelled)/i.test(message))
|
||||||
|
return { title: "Subscription expired", detail: message.replace(/\.$/, "") + ". Check your plan status with this provider." }
|
||||||
|
if (/rate.limit/i.test(message))
|
||||||
|
return { title: "Rate limited", detail: "Too many requests. Wait a moment and try again." }
|
||||||
|
if (/invalid.*key|invalid.*token|incorrect.*key/i.test(message))
|
||||||
|
return { title: "Invalid credentials", detail: "The API key was rejected. Double-check and try again." }
|
||||||
|
if (/quota|billing|payment/i.test(message))
|
||||||
|
return { title: "Billing issue", detail: message }
|
||||||
|
return { title: "Provider error", detail: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* not JSON */ }
|
||||||
|
|
||||||
|
if (/^401\b/i.test(raw)) return { title: "Unauthorized", detail: "The credentials were rejected. Double-check your API key." }
|
||||||
|
if (/^403\b/i.test(raw)) return { title: "Access denied", detail: "Your account doesn't have access. Check your subscription or permissions." }
|
||||||
|
if (/^429\b/i.test(raw)) return { title: "Rate limited", detail: "Too many requests. Wait a moment and try again." }
|
||||||
|
if (/^5\d{2}\b/i.test(raw)) return { title: "Server error", detail: "The provider returned an error. Try again in a minute." }
|
||||||
|
|
||||||
|
return { title: "Validation failed", detail: raw.length > 200 ? raw.slice(0, 200) + "…" : raw }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract a device code from instructions/prompt text */
|
||||||
|
function extractDeviceCode(flow: WorkspaceOnboardingFlowState): string | null {
|
||||||
|
const sources = [flow.prompt?.message, flow.auth?.instructions].filter(Boolean)
|
||||||
|
for (const src of sources) {
|
||||||
|
const match = src?.match(/(?:code|Code)[:\s]+([A-Z0-9]{4}[-–][A-Z0-9]{4})/i)
|
||||||
|
if (match) return match[1]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface StepAuthenticateProps {
|
||||||
|
provider: WorkspaceOnboardingProviderState
|
||||||
|
activeFlow: WorkspaceOnboardingFlowState | null
|
||||||
|
lastValidation: WorkspaceOnboardingValidationResult | null
|
||||||
|
requestState: WorkspaceOnboardingRequestState
|
||||||
|
requestProviderId: string | null
|
||||||
|
onSaveApiKey: (providerId: string, apiKey: string) => Promise<WorkspaceOnboardingState | null>
|
||||||
|
onStartFlow: (providerId: string) => void
|
||||||
|
onSubmitFlowInput: (flowId: string, input: string) => void
|
||||||
|
onCancelFlow: (flowId: string) => void
|
||||||
|
onBack: () => void
|
||||||
|
onNext: () => void
|
||||||
|
bridgeRefreshPhase: "idle" | "pending" | "succeeded" | "failed"
|
||||||
|
bridgeRefreshError: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepAuthenticate({
|
||||||
|
provider,
|
||||||
|
activeFlow,
|
||||||
|
lastValidation,
|
||||||
|
requestState,
|
||||||
|
requestProviderId,
|
||||||
|
onSaveApiKey,
|
||||||
|
onStartFlow,
|
||||||
|
onSubmitFlowInput,
|
||||||
|
onCancelFlow,
|
||||||
|
onBack,
|
||||||
|
onNext,
|
||||||
|
bridgeRefreshPhase,
|
||||||
|
bridgeRefreshError,
|
||||||
|
}: StepAuthenticateProps) {
|
||||||
|
const [apiKey, setApiKey] = useState("")
|
||||||
|
const [flowInput, setFlowInput] = useState("")
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const isBusy = requestState !== "idle"
|
||||||
|
const isThisProviderBusy = requestProviderId === provider.id && isBusy
|
||||||
|
const isValidated = lastValidation?.status === "succeeded" && lastValidation.providerId === provider.id
|
||||||
|
const isBridgeDone = bridgeRefreshPhase === "succeeded" || bridgeRefreshPhase === "idle"
|
||||||
|
const canProceed = isValidated && isBridgeDone
|
||||||
|
const validationFailed = lastValidation?.status === "failed" && lastValidation.providerId === provider.id
|
||||||
|
const parsedError = validationFailed ? parseValidationError(lastValidation.message) : null
|
||||||
|
|
||||||
|
const isOAuthOnly = !provider.supports.apiKey && provider.supports.oauth
|
||||||
|
const hasOAuth = provider.supports.oauth && provider.supports.oauthAvailable
|
||||||
|
const hasApiKey = provider.supports.apiKey
|
||||||
|
|
||||||
|
// Active flow state
|
||||||
|
const flowActive = activeFlow && activeFlow.providerId === provider.id && !canProceed
|
||||||
|
const flowFailed = flowActive && activeFlow.status === "failed"
|
||||||
|
const flowRunning = flowActive && (activeFlow.status === "running" || activeFlow.status === "awaiting_browser_auth")
|
||||||
|
const flowWaiting = flowActive && activeFlow.status === "awaiting_input"
|
||||||
|
const deviceCode = flowActive ? extractDeviceCode(activeFlow) : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastValidation?.status !== "succeeded") return
|
||||||
|
const t = window.setTimeout(() => setApiKey(""), 0)
|
||||||
|
return () => window.clearTimeout(t)
|
||||||
|
}, [lastValidation?.checkedAt, lastValidation?.status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setTimeout(() => setFlowInput(""), 0)
|
||||||
|
return () => window.clearTimeout(t)
|
||||||
|
}, [activeFlow?.flowId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!copied) return
|
||||||
|
const t = window.setTimeout(() => setCopied(false), 2000)
|
||||||
|
return () => window.clearTimeout(t)
|
||||||
|
}, [copied])
|
||||||
|
|
||||||
|
const copyCode = (code: string) => {
|
||||||
|
navigator.clipboard.writeText(code).then(() => setCopied(true)).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="text-center"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||||
|
Connect {provider.label}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{canProceed
|
||||||
|
? "Authenticated and ready to go."
|
||||||
|
: hasApiKey && hasOAuth
|
||||||
|
? "Paste an API key or sign in through your browser."
|
||||||
|
: hasApiKey
|
||||||
|
? "Paste your API key to authenticate."
|
||||||
|
: "Sign in through your browser to authenticate."}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.08, duration: 0.45 }}
|
||||||
|
className="mt-8 w-full max-w-md space-y-4"
|
||||||
|
>
|
||||||
|
{/* ─── Success state ─── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{canProceed && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
|
||||||
|
className="flex flex-col items-center gap-3 rounded-xl border border-success/15 bg-success/[0.04] px-6 py-6 text-center"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-success/15">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-success" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-foreground">{provider.label} authenticated</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ─── Validation error ─── */}
|
||||||
|
{validationFailed && parsedError && (
|
||||||
|
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm">
|
||||||
|
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-destructive">{parsedError.title}</div>
|
||||||
|
{parsedError.detail && <div className="mt-0.5 text-muted-foreground">{parsedError.detail}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Bridge refresh ─── */}
|
||||||
|
{bridgeRefreshPhase === "pending" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-foreground/10 bg-foreground/[0.03] px-4 py-3 text-sm text-foreground/80">
|
||||||
|
<LoaderCircle className="h-4 w-4 shrink-0 animate-spin" />
|
||||||
|
Connecting to provider…
|
||||||
|
</div>
|
||||||
|
<Progress value={66} className="h-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bridgeRefreshPhase === "failed" && bridgeRefreshError && (
|
||||||
|
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm">
|
||||||
|
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-destructive">Connection failed</div>
|
||||||
|
<div className="mt-0.5 text-muted-foreground">{bridgeRefreshError}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── API key form ─── */}
|
||||||
|
{hasApiKey && !canProceed && (
|
||||||
|
<div className="space-y-3 rounded-xl border border-border/50 bg-card/50 p-4">
|
||||||
|
<div className="text-sm font-medium text-foreground">API key</div>
|
||||||
|
<form
|
||||||
|
className="space-y-3"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!apiKey.trim()) return
|
||||||
|
const next = await onSaveApiKey(provider.id, apiKey)
|
||||||
|
if (next && !next.locked && (next.bridgeAuthRefresh.phase === "succeeded" || next.bridgeAuthRefresh.phase === "idle")) {
|
||||||
|
onNext()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
data-testid="onboarding-api-key-input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder={`Paste your ${provider.label} API key`}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!apiKey.trim() || isBusy}
|
||||||
|
className="gap-2 transition-transform active:scale-[0.96]"
|
||||||
|
data-testid="onboarding-save-api-key"
|
||||||
|
>
|
||||||
|
{isThisProviderBusy && requestState === "saving_api_key" ? (
|
||||||
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Validate & save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── OAuth section ─── */}
|
||||||
|
{hasOAuth && !canProceed && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Divider between API key and OAuth */}
|
||||||
|
{hasApiKey && (
|
||||||
|
<div className="flex items-center gap-3 py-1">
|
||||||
|
<div className="h-px flex-1 bg-border/50" />
|
||||||
|
<span className="text-xs text-muted-foreground">or</span>
|
||||||
|
<div className="h-px flex-1 bg-border/50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── No active flow: show start button ─── */}
|
||||||
|
{!flowActive && (
|
||||||
|
<div className="rounded-xl border border-border/50 bg-card/50 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-foreground">Browser sign-in</div>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Opens a new tab to authenticate with {provider.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => onStartFlow(provider.id)}
|
||||||
|
className="shrink-0 gap-2 transition-transform active:scale-[0.96]"
|
||||||
|
data-testid="onboarding-start-provider-flow"
|
||||||
|
>
|
||||||
|
{isThisProviderBusy && requestState === "starting_provider_flow" ? (
|
||||||
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Active flow: device code UX ─── */}
|
||||||
|
{flowActive && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="rounded-xl border border-border/50 bg-card/50 p-4 space-y-4"
|
||||||
|
data-testid="onboarding-active-flow"
|
||||||
|
>
|
||||||
|
{/* Device code — big and prominent */}
|
||||||
|
{deviceCode && (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-2">
|
||||||
|
<div className="text-xs text-muted-foreground">Enter this code on the sign-in page</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyCode(deviceCode)}
|
||||||
|
className="group flex items-center gap-3 rounded-lg border border-border bg-background/50 px-5 py-3 transition-colors hover:border-foreground/20 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-2xl font-bold tracking-[0.15em] text-foreground">
|
||||||
|
{deviceCode}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground transition-colors group-hover:text-muted-foreground">
|
||||||
|
{copied ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||||
|
) : (
|
||||||
|
<ClipboardCopy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
{copied ? "Copied!" : "Click to copy"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Instructions text (when no device code extracted) */}
|
||||||
|
{!deviceCode && activeFlow.auth?.instructions && (
|
||||||
|
<p className="text-sm text-muted-foreground">{activeFlow.auth.instructions}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Open sign-in page button */}
|
||||||
|
{activeFlow.auth?.url && (
|
||||||
|
<Button asChild className="w-full gap-2 transition-transform active:scale-[0.96]">
|
||||||
|
<a href={activeFlow.auth.url} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Open sign-in page
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status indicator */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
{flowRunning && (
|
||||||
|
<>
|
||||||
|
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||||
|
<span>Waiting for authentication…</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{flowFailed && (
|
||||||
|
<>
|
||||||
|
<XCircle className="h-3 w-3 text-destructive" />
|
||||||
|
<span className="text-destructive">Sign-in failed or timed out</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{flowWaiting && !deviceCode && (
|
||||||
|
<>
|
||||||
|
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||||
|
<span>Waiting for input…</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{flowFailed && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onStartFlow(provider.id)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="h-7 gap-1.5 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onCancelFlow(activeFlow.flowId)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="h-7 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generic prompt input (non-device-code) */}
|
||||||
|
{activeFlow.prompt && !deviceCode && (
|
||||||
|
<form
|
||||||
|
className="space-y-2 border-t border-border/50 pt-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!activeFlow.prompt?.allowEmpty && !flowInput.trim()) return
|
||||||
|
onSubmitFlowInput(activeFlow.flowId, flowInput)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-xs text-muted-foreground">{activeFlow.prompt.message}</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
data-testid="onboarding-flow-input"
|
||||||
|
value={flowInput}
|
||||||
|
onChange={(e) => setFlowInput(e.target.value)}
|
||||||
|
placeholder={activeFlow.prompt.placeholder || "Enter value"}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isBusy || (!activeFlow.prompt.allowEmpty && !flowInput.trim())}
|
||||||
|
className="shrink-0 transition-transform active:scale-[0.96]"
|
||||||
|
>
|
||||||
|
{requestState === "submitting_provider_flow_input" ? (
|
||||||
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Submit"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress messages */}
|
||||||
|
{activeFlow.progress.length > 0 && (
|
||||||
|
<div className="space-y-1 border-t border-border/50 pt-3">
|
||||||
|
{activeFlow.progress.map((message, i) => (
|
||||||
|
<div key={`${activeFlow.flowId}-${i}`} className="text-xs text-muted-foreground">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OAuth unavailable */}
|
||||||
|
{provider.supports.oauth && !provider.supports.oauthAvailable && !hasApiKey && (
|
||||||
|
<div className="rounded-xl border border-border/50 bg-card/50 px-4 py-3.5 text-sm text-muted-foreground">
|
||||||
|
Browser sign-in is not available in this runtime. Go back and choose a provider with API-key support.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.15, duration: 0.3 }}
|
||||||
|
className="mt-8 flex w-full max-w-md items-center justify-between"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-muted-foreground transition-transform active:scale-[0.96]"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!canProceed}
|
||||||
|
className="group gap-2 transition-transform active:scale-[0.96]"
|
||||||
|
data-testid="onboarding-auth-continue"
|
||||||
|
>
|
||||||
|
Configure another provider
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
369
web/components/sf/onboarding/step-dev-root.tsx
Normal file
369
web/components/sf/onboarding/step-dev-root.tsx
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { motion, AnimatePresence } from "motion/react"
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
ChevronRight,
|
||||||
|
CornerLeftUp,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
FolderRoot,
|
||||||
|
Loader2,
|
||||||
|
SkipForward,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { authFetch } from "@/lib/auth"
|
||||||
|
|
||||||
|
interface StepDevRootProps {
|
||||||
|
onNext: () => void
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUGGESTED_PATHS = ["~/Projects", "~/Developer", "~/Code", "~/dev"]
|
||||||
|
|
||||||
|
// ─── Inline folder browser ──────────────────────────────────────────
|
||||||
|
|
||||||
|
interface BrowseEntry {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineFolderBrowser({
|
||||||
|
onSelect,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
onSelect: (path: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [currentPath, setCurrentPath] = useState("")
|
||||||
|
const [parentPath, setParentPath] = useState<string | null>(null)
|
||||||
|
const [entries, setEntries] = useState<BrowseEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const browse = useCallback(async (targetPath?: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const param = targetPath ? `?path=${encodeURIComponent(targetPath)}` : ""
|
||||||
|
const res = await authFetch(`/api/browse-directories${param}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((body as { error?: string }).error ?? `${res.status}`)
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { current: string; parent: string | null; entries: BrowseEntry[] }
|
||||||
|
setCurrentPath(data.current)
|
||||||
|
setParentPath(data.parent)
|
||||||
|
setEntries(data.entries)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to browse")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void browse()
|
||||||
|
}, [browse])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border/50 bg-card/50 overflow-hidden">
|
||||||
|
{/* Current path */}
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-border/50 px-4 py-2.5">
|
||||||
|
<p className="min-w-0 truncate font-mono text-xs text-muted-foreground" title={currentPath}>
|
||||||
|
{currentPath}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSelect(currentPath)}
|
||||||
|
className="shrink-0 h-7 gap-1.5 text-xs transition-transform active:scale-[0.96]"
|
||||||
|
>
|
||||||
|
Select this folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Directory listing */}
|
||||||
|
<ScrollArea className="h-[240px]">
|
||||||
|
<div className="px-1.5 py-1">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="px-3 py-4 text-center text-xs text-destructive">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{parentPath && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void browse(parentPath)}
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<CornerLeftUp className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">..</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.path}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void browse(entry.path)}
|
||||||
|
className="group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-foreground">{entry.name}</span>
|
||||||
|
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/50 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{entries.length === 0 && !parentPath && (
|
||||||
|
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
|
||||||
|
No subdirectories
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Cancel */}
|
||||||
|
<div className="border-t border-border/50 px-4 py-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="h-7 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main step ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function StepDevRoot({ onNext, onBack }: StepDevRootProps) {
|
||||||
|
const [path, setPath] = useState("")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [browsing, setBrowsing] = useState(false)
|
||||||
|
|
||||||
|
const handleSuggestionClick = useCallback((suggestion: string) => {
|
||||||
|
setPath(suggestion)
|
||||||
|
setError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleContinue = useCallback(async () => {
|
||||||
|
const trimmed = path.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setError("Enter a path or skip this step")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authFetch("/api/preferences", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ devRoot: trimmed }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(
|
||||||
|
(body as { error?: string }).error ?? `Request failed (${res.status})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onNext()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to save preference")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [path, onNext])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
{/* Icon */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.85 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-xl border border-border/50 bg-card/50">
|
||||||
|
<FolderRoot className="h-7 w-7 text-foreground/80" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.06, duration: 0.4 }}
|
||||||
|
className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl"
|
||||||
|
>
|
||||||
|
Dev root
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.12, duration: 0.4 }}
|
||||||
|
className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground"
|
||||||
|
>
|
||||||
|
The folder that contains your projects. GSD discovers and manages workspaces inside it.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{/* Input + browse */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.18, duration: 0.45 }}
|
||||||
|
className="mt-8 w-full max-w-md space-y-4"
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{browsing ? (
|
||||||
|
<motion.div
|
||||||
|
key="browser"
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<InlineFolderBrowser
|
||||||
|
onSelect={(selected) => {
|
||||||
|
setPath(selected)
|
||||||
|
setBrowsing(false)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
onCancel={() => setBrowsing(false)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div key="input" className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={path}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPath(e.target.value)
|
||||||
|
if (error) setError(null)
|
||||||
|
}}
|
||||||
|
placeholder="/Users/you/Projects"
|
||||||
|
className={cn(
|
||||||
|
"h-11 flex-1 font-mono text-sm",
|
||||||
|
error && "border-destructive/50 focus-visible:ring-destructive/30",
|
||||||
|
)}
|
||||||
|
data-testid="onboarding-devroot-input"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && path.trim()) {
|
||||||
|
void handleContinue()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBrowsing(true)}
|
||||||
|
className="h-11 gap-2 shrink-0 transition-transform active:scale-[0.96]"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suggestions */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
{SUGGESTED_PATHS.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSuggestionClick(suggestion)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1 font-mono text-xs transition-all duration-150",
|
||||||
|
"active:scale-[0.96]",
|
||||||
|
path === suggestion
|
||||||
|
? "border-foreground/25 bg-foreground/10 text-foreground"
|
||||||
|
: "border-border/50 text-muted-foreground hover:border-foreground/15 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.25, duration: 0.3 }}
|
||||||
|
className="mt-8 flex w-full max-w-md items-center justify-between"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-muted-foreground transition-transform active:scale-[0.96]"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onNext}
|
||||||
|
className="gap-1.5 text-muted-foreground transition-transform active:scale-[0.96]"
|
||||||
|
data-testid="onboarding-devroot-skip"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
<SkipForward className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleContinue()}
|
||||||
|
className="group gap-2 transition-transform active:scale-[0.96]"
|
||||||
|
disabled={saving || browsing}
|
||||||
|
data-testid="onboarding-devroot-continue"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
186
web/components/sf/onboarding/step-mode.tsx
Normal file
186
web/components/sf/onboarding/step-mode.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { motion } from "motion/react"
|
||||||
|
import { ArrowRight, Code2, MessageCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { UserMode } from "@/lib/use-user-mode"
|
||||||
|
|
||||||
|
interface StepModeProps {
|
||||||
|
selected: UserMode | null
|
||||||
|
onSelect: (mode: UserMode) => void
|
||||||
|
onNext: () => void
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_OPTIONS: {
|
||||||
|
id: UserMode
|
||||||
|
label: string
|
||||||
|
icon: typeof Code2
|
||||||
|
tagline: string
|
||||||
|
description: string
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
id: "expert",
|
||||||
|
label: "Expert",
|
||||||
|
icon: Code2,
|
||||||
|
tagline: "Full control",
|
||||||
|
description:
|
||||||
|
"Dashboard metrics, dual-pane power mode, and direct /gsd command access. Built for people who want visibility into every milestone and task.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vibe-coder",
|
||||||
|
label: "Vibe Coder",
|
||||||
|
icon: MessageCircle,
|
||||||
|
tagline: "Just chat",
|
||||||
|
description:
|
||||||
|
"Conversational interface with the AI agent. Describe what you want and let GSD handle the structure. Same engine, friendlier surface.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function StepMode({ selected, onSelect, onNext, onBack }: StepModeProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl"
|
||||||
|
style={{ textWrap: "balance" } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
How do you want to work?
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.06, duration: 0.4 }}
|
||||||
|
className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground"
|
||||||
|
>
|
||||||
|
You can switch anytime from settings.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.12, duration: 0.45 }}
|
||||||
|
className="mt-8 grid w-full max-w-lg gap-3 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
{MODE_OPTIONS.map((opt) => {
|
||||||
|
const isSelected = selected === opt.id
|
||||||
|
const Icon = opt.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(opt.id)}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex flex-col rounded-xl border px-5 py-5 text-left transition-all duration-200",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
"active:scale-[0.98]",
|
||||||
|
isSelected
|
||||||
|
? "border-foreground/30 bg-foreground/[0.06] shadow-[0_0_0_1px_rgba(255,255,255,0.06)]"
|
||||||
|
: "border-border/50 bg-card/50 hover:border-foreground/15 hover:bg-card/50",
|
||||||
|
)}
|
||||||
|
data-testid={`onboarding-mode-${opt.id}`}
|
||||||
|
>
|
||||||
|
{/* Selection indicator */}
|
||||||
|
<div className="absolute right-3.5 top-3.5">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-5 w-5 items-center justify-center rounded-full border-[1.5px] transition-all duration-200",
|
||||||
|
isSelected
|
||||||
|
? "border-foreground bg-foreground"
|
||||||
|
: "border-foreground/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<motion.svg
|
||||||
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
className="h-2.5 w-2.5 text-background"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="2.5 6 5 8.5 9.5 3.5" />
|
||||||
|
</motion.svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mb-4 flex h-10 w-10 items-center justify-center rounded-lg transition-colors duration-200",
|
||||||
|
isSelected
|
||||||
|
? "bg-foreground/10"
|
||||||
|
: "bg-foreground/[0.04]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-5 transition-colors duration-200",
|
||||||
|
isSelected ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label + tagline */}
|
||||||
|
<div className="pr-7">
|
||||||
|
<span className="text-[15px] font-semibold text-foreground">
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-2 text-xs font-medium transition-colors duration-200",
|
||||||
|
isSelected ? "text-muted-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.tagline}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="mt-2 text-[13px] leading-relaxed text-muted-foreground">
|
||||||
|
{opt.description}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.3 }}
|
||||||
|
className="mt-8 flex w-full max-w-lg items-center justify-between"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-muted-foreground transition-transform active:scale-[0.96]"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!selected}
|
||||||
|
className="group gap-2 transition-transform active:scale-[0.96]"
|
||||||
|
data-testid="onboarding-mode-continue"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
161
web/components/sf/onboarding/step-optional.tsx
Normal file
161
web/components/sf/onboarding/step-optional.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { motion } from "motion/react"
|
||||||
|
import { ArrowRight, Check, CircleDashed } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import type { WorkspaceOnboardingOptionalSectionState } from "@/lib/sf-workspace-store"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface StepOptionalProps {
|
||||||
|
sections: WorkspaceOnboardingOptionalSectionState[]
|
||||||
|
onBack: () => void
|
||||||
|
onNext: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepOptional({ sections, onBack, onNext }: StepOptionalProps) {
|
||||||
|
// Remote questions has its own dedicated step — don't show it here
|
||||||
|
const filtered = sections.filter((s) => s.id !== "remote_questions")
|
||||||
|
const configuredCount = filtered.filter((s) => s.configured).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="text-center"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||||
|
Integrations
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Optional tools. Nothing here blocks the workspace — configure later from settings.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{configuredCount > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.08, duration: 0.3 }}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-success">{configuredCount}</span>
|
||||||
|
{" of "}
|
||||||
|
{filtered.length} configured
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1, duration: 0.45 }}
|
||||||
|
className="mt-8 w-full space-y-2"
|
||||||
|
>
|
||||||
|
{filtered.map((section) => (
|
||||||
|
<div
|
||||||
|
key={section.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-3.5 rounded-xl border px-4 py-3.5 transition-colors",
|
||||||
|
section.configured
|
||||||
|
? "border-success/15 bg-success/[0.03]"
|
||||||
|
: "border-border/50 bg-card/50",
|
||||||
|
)}
|
||||||
|
data-testid={`onboarding-optional-${section.id}`}
|
||||||
|
>
|
||||||
|
{/* Status dot */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full",
|
||||||
|
section.configured
|
||||||
|
? "bg-success/15 text-success"
|
||||||
|
: "bg-foreground/[0.05] text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{section.configured ? (
|
||||||
|
<Check className="h-3 w-3" strokeWidth={3} />
|
||||||
|
) : (
|
||||||
|
<CircleDashed className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">{section.label}</span>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[10px]",
|
||||||
|
section.configured
|
||||||
|
? "border-success/15 text-success/70"
|
||||||
|
: "border-border/50 text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{section.configured ? "Ready" : "Skipped"}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{section.configured
|
||||||
|
? "This integration is configured and active"
|
||||||
|
: "You can set this up later from workspace settings"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{section.configuredItems.length > 0 && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
{section.configuredItems.map((item) => (
|
||||||
|
<Badge
|
||||||
|
key={item}
|
||||||
|
variant="outline"
|
||||||
|
className="border-border/50 text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section.configuredItems.length === 0 && (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Not configured — add later from settings.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.18, duration: 0.3 }}
|
||||||
|
className="mt-8 flex w-full items-center justify-between"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-muted-foreground transition-transform active:scale-[0.96]"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
className="group gap-2 transition-transform active:scale-[0.96]"
|
||||||
|
data-testid="onboarding-optional-continue"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
470
web/components/sf/onboarding/step-project.tsx
Normal file
470
web/components/sf/onboarding/step-project.tsx
Normal file
|
|
@ -0,0 +1,470 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { motion } from "motion/react"
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
FolderOpen,
|
||||||
|
GitBranch,
|
||||||
|
Layers,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Sparkles,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { useProjectStoreManager } from "@/lib/project-store-manager"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { authFetch } from "@/lib/auth"
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ProjectDetectionKind = "active-gsd" | "empty-gsd" | "v1-legacy" | "brownfield" | "blank"
|
||||||
|
|
||||||
|
interface ProjectDetectionSignals {
|
||||||
|
hasGsdFolder: boolean
|
||||||
|
hasPlanningFolder: boolean
|
||||||
|
hasGitRepo: boolean
|
||||||
|
hasPackageJson: boolean
|
||||||
|
fileCount: number
|
||||||
|
hasMilestones?: boolean
|
||||||
|
hasCargo?: boolean
|
||||||
|
hasGoMod?: boolean
|
||||||
|
hasPyproject?: boolean
|
||||||
|
isMonorepo?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectProgressInfo {
|
||||||
|
activeMilestone: string | null
|
||||||
|
activeSlice: string | null
|
||||||
|
phase: string | null
|
||||||
|
milestonesCompleted: number
|
||||||
|
milestonesTotal: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectMetadata {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
kind: ProjectDetectionKind
|
||||||
|
signals: ProjectDetectionSignals
|
||||||
|
lastModified: number
|
||||||
|
progress?: ProjectProgressInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const KIND_STYLE: Record<ProjectDetectionKind, { label: string; color: string; icon: typeof Layers }> = {
|
||||||
|
"active-gsd": { label: "Active", color: "text-success", icon: Layers },
|
||||||
|
"empty-gsd": { label: "Initialized", color: "text-info", icon: FolderOpen },
|
||||||
|
brownfield: { label: "Existing", color: "text-warning", icon: GitBranch },
|
||||||
|
"v1-legacy": { label: "Legacy", color: "text-warning", icon: GitBranch },
|
||||||
|
blank: { label: "New", color: "text-muted-foreground", icon: Sparkles },
|
||||||
|
}
|
||||||
|
|
||||||
|
function techStack(signals: ProjectDetectionSignals): string[] {
|
||||||
|
const tags: string[] = []
|
||||||
|
if (signals.isMonorepo) tags.push("Monorepo")
|
||||||
|
if (signals.hasGitRepo) tags.push("Git")
|
||||||
|
if (signals.hasPackageJson) tags.push("Node.js")
|
||||||
|
if (signals.hasCargo) tags.push("Rust")
|
||||||
|
if (signals.hasGoMod) tags.push("Go")
|
||||||
|
if (signals.hasPyproject) tags.push("Python")
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressLabel(p: ProjectProgressInfo): string | null {
|
||||||
|
if (p.milestonesTotal === 0) return null
|
||||||
|
const parts: string[] = []
|
||||||
|
if (p.activeMilestone) parts.push(p.activeMilestone)
|
||||||
|
if (p.activeSlice) parts.push(p.activeSlice)
|
||||||
|
if (p.phase) parts.push(p.phase)
|
||||||
|
return parts.join(" · ") || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortenPath(p: string): string {
|
||||||
|
const home = typeof window !== "undefined" ? "" : ""
|
||||||
|
// Show last 2-3 segments
|
||||||
|
const parts = p.split("/").filter(Boolean)
|
||||||
|
if (parts.length <= 3) return p
|
||||||
|
return "…/" + parts.slice(-2).join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface StepProjectProps {
|
||||||
|
onFinish: (projectPath: string) => void
|
||||||
|
onBack: () => void
|
||||||
|
/** Called immediately before a project switch starts — use to disarm gates. */
|
||||||
|
onBeforeSwitch?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepProject({ onFinish, onBack, onBeforeSwitch }: StepProjectProps) {
|
||||||
|
const manager = useProjectStoreManager()
|
||||||
|
|
||||||
|
const [devRoot, setDevRoot] = useState<string | null>(null)
|
||||||
|
const [projects, setProjects] = useState<ProjectMetadata[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [newName, setNewName] = useState("")
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
|
const createInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const [switchingTo, setSwitchingTo] = useState<string | null>(null)
|
||||||
|
const switchPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const prefsRes = await authFetch("/api/preferences")
|
||||||
|
if (!prefsRes.ok) throw new Error("Failed to load preferences")
|
||||||
|
const prefs = await prefsRes.json()
|
||||||
|
if (!prefs.devRoot) { setDevRoot(null); setProjects([]); setLoading(false); return }
|
||||||
|
setDevRoot(prefs.devRoot)
|
||||||
|
const projRes = await authFetch(`/api/projects?root=${encodeURIComponent(prefs.devRoot)}&detail=true`)
|
||||||
|
if (!projRes.ok) throw new Error("Failed to discover projects")
|
||||||
|
const discovered = (await projRes.json()) as ProjectMetadata[]
|
||||||
|
if (!cancelled) setProjects(discovered)
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : "Unknown error")
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { if (switchPollRef.current) clearInterval(switchPollRef.current) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showCreate) {
|
||||||
|
const t = setTimeout(() => createInputRef.current?.focus(), 50)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}
|
||||||
|
}, [showCreate])
|
||||||
|
|
||||||
|
const existingNames = projects.map((p) => p.name)
|
||||||
|
const nameValid = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(newName)
|
||||||
|
const nameConflict = existingNames.includes(newName)
|
||||||
|
const canCreate = newName.length > 0 && nameValid && !nameConflict && !creating
|
||||||
|
|
||||||
|
const handleSelectProject = useCallback((project: ProjectMetadata) => {
|
||||||
|
onBeforeSwitch?.()
|
||||||
|
setSwitchingTo(project.path)
|
||||||
|
const store = manager.switchProject(project.path)
|
||||||
|
if (switchPollRef.current) clearInterval(switchPollRef.current)
|
||||||
|
const startTime = Date.now()
|
||||||
|
switchPollRef.current = setInterval(() => {
|
||||||
|
const state = store.getSnapshot()
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
if (state.bootStatus === "ready" || state.bootStatus === "error" || elapsed > 30000) {
|
||||||
|
if (switchPollRef.current) clearInterval(switchPollRef.current)
|
||||||
|
switchPollRef.current = null
|
||||||
|
setSwitchingTo(null)
|
||||||
|
onFinish(project.path)
|
||||||
|
}
|
||||||
|
}, 150)
|
||||||
|
}, [manager, onFinish, onBeforeSwitch])
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async () => {
|
||||||
|
if (!canCreate || !devRoot) return
|
||||||
|
setCreating(true)
|
||||||
|
setCreateError(null)
|
||||||
|
try {
|
||||||
|
const res = await authFetch("/api/projects", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ devRoot, name: newName }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((body as { error?: string }).error ?? `Failed (${res.status})`)
|
||||||
|
}
|
||||||
|
const project = (await res.json()) as ProjectMetadata
|
||||||
|
setProjects((prev) => [...prev, project].sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
setNewName("")
|
||||||
|
setShowCreate(false)
|
||||||
|
handleSelectProject(project)
|
||||||
|
} catch (err) {
|
||||||
|
setCreateError(err instanceof Error ? err.message : "Failed to create project")
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}, [canCreate, devRoot, newName, handleSelectProject])
|
||||||
|
|
||||||
|
const noDevRoot = !loading && !devRoot
|
||||||
|
|
||||||
|
// Sort: active-gsd first, then by name
|
||||||
|
const sortedProjects = [...projects].sort((a, b) => {
|
||||||
|
const kindOrder: Record<ProjectDetectionKind, number> = { "active-gsd": 0, "empty-gsd": 1, brownfield: 2, "v1-legacy": 3, blank: 4 }
|
||||||
|
const ka = kindOrder[a.kind] ?? 5
|
||||||
|
const kb = kindOrder[b.kind] ?? 5
|
||||||
|
if (ka !== kb) return ka - kb
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="text-center"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||||
|
Open a project
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{noDevRoot
|
||||||
|
? "Set a dev root first to discover your projects."
|
||||||
|
: "Pick a project to start working in, or create a new one."}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.08, duration: 0.45 }}
|
||||||
|
className="mt-8 w-full max-w-lg space-y-2"
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-10 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Discovering projects…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{noDevRoot && (
|
||||||
|
<div className="rounded-xl border border-border/50 bg-card/50 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No dev root configured. Go back and set one, or finish setup to configure later.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Project cards */}
|
||||||
|
{!loading && sortedProjects.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedProjects.map((project) => {
|
||||||
|
const isSwitching = switchingTo === project.path
|
||||||
|
const style = KIND_STYLE[project.kind]
|
||||||
|
const KindIcon = style.icon
|
||||||
|
const stack = techStack(project.signals)
|
||||||
|
const progress = project.progress ? progressLabel(project.progress) : null
|
||||||
|
const milestoneCount = project.progress
|
||||||
|
? `${project.progress.milestonesCompleted}/${project.progress.milestonesTotal}`
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={project.path}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectProject(project)}
|
||||||
|
disabled={!!switchingTo}
|
||||||
|
className={cn(
|
||||||
|
"group flex w-full items-start gap-3.5 rounded-xl border px-4 py-3.5 text-left transition-all duration-200",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
"active:scale-[0.98]",
|
||||||
|
isSwitching
|
||||||
|
? "border-foreground/30 bg-foreground/[0.06]"
|
||||||
|
: "border-border/50 bg-card/50 hover:border-foreground/15 hover:bg-card/50",
|
||||||
|
switchingTo && !isSwitching && "opacity-40 pointer-events-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg mt-0.5",
|
||||||
|
project.kind === "active-gsd" ? "bg-success/10" : "bg-foreground/[0.04]",
|
||||||
|
)}>
|
||||||
|
{isSwitching ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<KindIcon className={cn("h-4 w-4", style.color)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Row 1: name + kind badge */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground truncate">{project.name}</span>
|
||||||
|
<span className={cn("text-[10px] font-medium shrink-0", style.color)}>
|
||||||
|
{style.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: tech stack tags */}
|
||||||
|
{stack.length > 0 && (
|
||||||
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
|
{stack.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded bg-foreground/[0.04] px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 3: progress info (for active-gsd projects) */}
|
||||||
|
{progress && (
|
||||||
|
<div className="mt-1.5 text-[11px] text-muted-foreground">
|
||||||
|
{progress}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 4: milestone bar (for active-gsd with milestones) */}
|
||||||
|
{project.progress && project.progress.milestonesTotal > 0 && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<div className="h-1 flex-1 overflow-hidden rounded-full bg-foreground/[0.06]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-success/60 transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.round((project.progress.milestonesCompleted / project.progress.milestonesTotal) * 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||||
|
{milestoneCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-muted-foreground/50 transition-all group-hover:text-muted-foreground group-hover:translate-x-0.5" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && devRoot && projects.length === 0 && !error && (
|
||||||
|
<div className="rounded-xl border border-border/50 bg-card/50 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No projects found in {devRoot}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create new project */}
|
||||||
|
{!loading && devRoot && (
|
||||||
|
<>
|
||||||
|
{!showCreate ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
disabled={!!switchingTo}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3.5 rounded-xl border border-dashed px-4 py-3.5 text-left transition-all duration-200",
|
||||||
|
"border-border/50 text-muted-foreground hover:border-foreground/15 hover:text-foreground",
|
||||||
|
"active:scale-[0.98]",
|
||||||
|
switchingTo && "opacity-40 pointer-events-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-foreground/[0.04]">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">Create new project</span>
|
||||||
|
<p className="mt-0.5 text-[11px] text-muted-foreground">Initialize a new directory with Git</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="rounded-xl border border-border/50 bg-card/50 p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-foreground">New project</div>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => { e.preventDefault(); void handleCreate() }}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={createInputRef}
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => { setNewName(e.target.value); setCreateError(null) }}
|
||||||
|
placeholder="my-project"
|
||||||
|
autoComplete="off"
|
||||||
|
className="text-sm"
|
||||||
|
disabled={creating}
|
||||||
|
/>
|
||||||
|
{newName && !nameValid && (
|
||||||
|
<p className="text-xs text-destructive">Letters, numbers, hyphens, underscores, dots. Must start with a letter or number.</p>
|
||||||
|
)}
|
||||||
|
{nameConflict && (
|
||||||
|
<p className="text-xs text-destructive">A project with this name already exists</p>
|
||||||
|
)}
|
||||||
|
{createError && (
|
||||||
|
<p className="text-xs text-destructive">{createError}</p>
|
||||||
|
)}
|
||||||
|
{newName && nameValid && !nameConflict && (
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">{devRoot}/{newName}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canCreate}
|
||||||
|
className="gap-1.5 transition-transform active:scale-[0.96]"
|
||||||
|
>
|
||||||
|
{creating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||||||
|
Create & open
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setShowCreate(false); setNewName(""); setCreateError(null) }}
|
||||||
|
disabled={creating}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.15, duration: 0.3 }}
|
||||||
|
className="mt-8 flex w-full max-w-lg items-center justify-between"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-muted-foreground transition-transform active:scale-[0.96]"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => { onBeforeSwitch?.(); onFinish("") }}
|
||||||
|
className="group gap-2 transition-transform active:scale-[0.96]"
|
||||||
|
>
|
||||||
|
Finish setup
|
||||||
|
<Zap className="h-4 w-4 transition-transform group-hover:scale-110" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue