Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70957e462a | |||
| 684a11610a |
@@ -87,6 +87,31 @@ Do not claim completion without verification evidence.
|
||||
|
||||
## Git workflow
|
||||
|
||||
### Branching strategy
|
||||
|
||||
For every spec change or new functionality:
|
||||
|
||||
1. Create a new branch from `dev` with a proper prefix:
|
||||
- `feat/` for new features (e.g., `feat/tool-workshop`)
|
||||
- `fix/` for bug fixes (e.g., `fix/terminal-tty`)
|
||||
- `refactor/` for refactors (e.g., `refactor/api-cleanup`)
|
||||
- `docs/` for documentation (e.g., `docs/api-guide`)
|
||||
- `chore/` for maintenance (e.g., `chore/update-deps`)
|
||||
2. Branch name should reference the OpenSpec change name when applicable.
|
||||
3. Do not commit directly to `main` or `dev`.
|
||||
|
||||
### Completion and merge
|
||||
|
||||
When implementation is complete and verified:
|
||||
|
||||
1. Ensure all tests pass and quality gates are met.
|
||||
2. Stage all changes with `git add -A`.
|
||||
3. Create a commit with a proper conventional commit message (see below).
|
||||
4. Switch to `dev`: `git checkout dev`.
|
||||
5. Merge the feature branch: `git merge --no-ff <branch-name>`.
|
||||
6. Push to remote: `git push origin dev`.
|
||||
7. Delete the local feature branch if desired: `git branch -d <branch-name>`.
|
||||
|
||||
### Auto-commit on spec completion
|
||||
|
||||
When an OpenSpec change is fully implemented and all tasks are complete:
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
"""add config profiles, includes, mounts, and tool instance profile selection
|
||||
|
||||
Revision ID: 0013_add_config_profiles
|
||||
Revises: 0012_default_port_req
|
||||
Create Date: 2026-05-24 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0013_add_config_profiles"
|
||||
down_revision: Union[str, None] = "0012_default_port_req"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create config_profiles table
|
||||
op.create_table(
|
||||
"config_profiles",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||
)
|
||||
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
|
||||
|
||||
# Create config_includes table
|
||||
op.create_table(
|
||||
"config_includes",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["included_profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
|
||||
)
|
||||
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
|
||||
op.create_index("idx_config_includes_included", "config_includes", ["included_profile_id"])
|
||||
|
||||
# Create config_mounts table
|
||||
op.create_table(
|
||||
"config_mounts",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("mount_path", sa.String(length=1024), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
|
||||
|
||||
# Add selected_profile_id to tool_instances
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_tool_instances_selected_profile",
|
||||
"tool_instances",
|
||||
"config_profiles",
|
||||
["selected_profile_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.create_index("idx_tool_instances_selected_profile", "tool_instances", ["selected_profile_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove selected_profile_id from tool_instances
|
||||
op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances")
|
||||
op.drop_constraint("fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey")
|
||||
op.drop_column("tool_instances", "selected_profile_id")
|
||||
|
||||
# Drop config_mounts
|
||||
op.drop_index("idx_config_mounts_profile", table_name="config_mounts")
|
||||
op.drop_table("config_mounts")
|
||||
|
||||
# Drop config_includes
|
||||
op.drop_index("idx_config_includes_included", table_name="config_includes")
|
||||
op.drop_index("idx_config_includes_profile", table_name="config_includes")
|
||||
op.drop_table("config_includes")
|
||||
|
||||
# Drop config_profiles
|
||||
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
|
||||
op.drop_table("config_profiles")
|
||||
@@ -1,8 +1,5 @@
|
||||
from src.models.base import Base
|
||||
from src.models.config_folder import ConfigFolder
|
||||
from src.models.config_include import ConfigInclude
|
||||
from src.models.config_mount import ConfigMount
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
@@ -11,17 +8,4 @@ from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"ConfigFolder",
|
||||
"ConfigInclude",
|
||||
"ConfigMount",
|
||||
"ConfigProfile",
|
||||
"GitRepository",
|
||||
"Project",
|
||||
"SSHKey",
|
||||
"ToolInstance",
|
||||
"ToolType",
|
||||
"User",
|
||||
"UserConfig",
|
||||
]
|
||||
__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
||||
|
||||
@@ -26,8 +26,6 @@ class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
project_overrides: Mapped[dict | None] = mapped_column(
|
||||
JSON, default=dict, nullable=True
|
||||
) # {"project_id": {"mount_path": "...", "files": {...}}}
|
||||
# DEPRECATED: Legacy auto-mounting flag. No longer used for launch-time
|
||||
# auto-mounting. Use ConfigProfile and ToolInstance.selected_profile_id instead.
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship()
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.config_profile import ConfigProfile
|
||||
|
||||
|
||||
class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "config_includes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
|
||||
)
|
||||
|
||||
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
profile: Mapped["ConfigProfile"] = relationship(
|
||||
"ConfigProfile",
|
||||
foreign_keys=[profile_id],
|
||||
back_populates="includes",
|
||||
)
|
||||
included_profile: Mapped["ConfigProfile"] = relationship(
|
||||
"ConfigProfile",
|
||||
foreign_keys=[included_profile_id],
|
||||
)
|
||||
@@ -1,35 +0,0 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.config_profile import ConfigProfile
|
||||
|
||||
|
||||
class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "config_mounts"
|
||||
|
||||
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
profile: Mapped["ConfigProfile"] = relationship(
|
||||
"ConfigProfile",
|
||||
foreign_keys=[profile_id],
|
||||
back_populates="mounts",
|
||||
)
|
||||
source_profile: Mapped["ConfigProfile | None"] = relationship(
|
||||
"ConfigProfile",
|
||||
foreign_keys=[source_profile_id],
|
||||
)
|
||||
@@ -1,39 +0,0 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "config_profiles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||
)
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
user: Mapped["User"] = relationship()
|
||||
includes: Mapped[list["ConfigInclude"]] = relationship(
|
||||
"ConfigInclude",
|
||||
foreign_keys="ConfigInclude.profile_id",
|
||||
back_populates="profile",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ConfigInclude.order_index",
|
||||
)
|
||||
mounts: Mapped[list["ConfigMount"]] = relationship(
|
||||
"ConfigMount",
|
||||
back_populates="profile",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ConfigMount.order_index",
|
||||
)
|
||||
@@ -9,7 +9,6 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.tool_type import ToolType
|
||||
@@ -63,12 +62,8 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
selected_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
tool_type: Mapped["ToolType"] = relationship()
|
||||
repository: Mapped["GitRepository"] = relationship()
|
||||
project: Mapped["Project"] = relationship()
|
||||
owner: Mapped["User"] = relationship()
|
||||
selected_profile: Mapped["ConfigProfile | None"] = relationship()
|
||||
|
||||
@@ -18,23 +18,3 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="user_config")
|
||||
|
||||
@property
|
||||
def default_profile_id(self) -> uuid.UUID | None:
|
||||
profile_id = self.config.get("default_profile_id")
|
||||
return uuid.UUID(profile_id) if profile_id else None
|
||||
|
||||
@default_profile_id.setter
|
||||
def default_profile_id(self, value: uuid.UUID | None) -> None:
|
||||
if value is not None:
|
||||
self.config["default_profile_id"] = str(value)
|
||||
elif "default_profile_id" in self.config:
|
||||
del self.config["default_profile_id"]
|
||||
|
||||
@property
|
||||
def default_profiles(self) -> dict[str, str]:
|
||||
return self.config.get("default_profiles", {})
|
||||
|
||||
@default_profiles.setter
|
||||
def default_profiles(self, value: dict[str, str]) -> None:
|
||||
self.config["default_profiles"] = value
|
||||
|
||||
@@ -121,11 +121,12 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
|
||||
Raises:
|
||||
RuntimeError: If branch creation fails
|
||||
"""
|
||||
try:
|
||||
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
except RuntimeError:
|
||||
_run_git_command(repo_path, "checkout", "--orphan", name)
|
||||
return
|
||||
if base_branch == "HEAD":
|
||||
try:
|
||||
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD")
|
||||
except RuntimeError:
|
||||
_run_git_command(repo_path, "checkout", "--orphan", name)
|
||||
return
|
||||
|
||||
_run_git_command(repo_path, "branch", name, base_branch)
|
||||
|
||||
|
||||
@@ -39,18 +39,3 @@ def test_refresh_tokens_migration_has_expected_revision_chain() -> None:
|
||||
|
||||
assert module.revision == "0002_refresh_tokens"
|
||||
assert module.down_revision == "0001_initial_schema"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_config_profiles_migration_has_expected_revision_chain() -> None:
|
||||
migration_path = Path(__file__).resolve().parents[2] / "alembic" / "versions" / "0013_add_config_profiles.py"
|
||||
spec = spec_from_file_location("add_config_profiles", migration_path)
|
||||
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
assert module.revision == "0013_add_config_profiles"
|
||||
assert module.down_revision == "0012_default_port_req"
|
||||
|
||||
Generated
-1371
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Headquarter</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,796 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Headquarter - UI Preview</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f1ea;
|
||||
--panel: #fffef9;
|
||||
--ink: #1d1d1b;
|
||||
--muted: #5f5b55;
|
||||
--brand: #275d4b;
|
||||
--brand-strong: #154236;
|
||||
--border: #d8d0c5;
|
||||
--primary: #275d4b;
|
||||
--primary-fg: #fffef9;
|
||||
--color-primary: #275d4b;
|
||||
--success: #2f8f62;
|
||||
--success-light: rgba(47, 143, 98, 0.14);
|
||||
--warning: #c08a1e;
|
||||
--warning-light: rgba(192, 138, 30, 0.14);
|
||||
--danger: #b94a3c;
|
||||
--danger-light: rgba(185, 74, 60, 0.14);
|
||||
--info: #4f7fb8;
|
||||
--info-light: rgba(79, 127, 184, 0.14);
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.5rem;
|
||||
--space-6: 2rem;
|
||||
--font-size-xs: clamp(0.625rem, 0.6rem + 0.125vw, 0.75rem);
|
||||
--font-size-sm: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
|
||||
--font-size-base: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
|
||||
--font-size-lg: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
|
||||
--font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* App Shell */
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shell-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
padding: 0.58rem 0.85rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.shell-body {
|
||||
display: grid;
|
||||
grid-template-columns: 230px 1fr;
|
||||
min-height: calc(100vh - 57px);
|
||||
}
|
||||
|
||||
.shell-nav {
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 1rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
background: color-mix(in srgb, var(--panel) 65%, transparent);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #ece7df;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.nav-item-active {
|
||||
background: var(--brand);
|
||||
color: #f7fff7;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
background: var(--primary);
|
||||
color: var(--primary-fg);
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.session-item {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.session-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.session-status.running {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.shell-content {
|
||||
padding: 1.25rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Common Components */
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stack-sm {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.58rem 0.85rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.primary-button:hover {
|
||||
background: var(--brand-strong);
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
border-color: var(--border);
|
||||
background: var(--panel);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.58rem 0.85rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Home Page */
|
||||
.home-page {
|
||||
max-width: 1240px;
|
||||
}
|
||||
|
||||
.home-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.home-hero-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.home-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.home-summary-card .card-label {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.home-summary-card .card-value {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.home-section h2,
|
||||
.home-section h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.home-session-grid,
|
||||
.home-project-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.session-card {
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-badge.running {
|
||||
background: var(--success-light);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-badge.building {
|
||||
background: var(--warning-light);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: var(--info-light);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.recent-sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.recent-session-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.recent-session-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.create-session-form .form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.form-field input,
|
||||
.form-field select,
|
||||
.form-field textarea {
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Settings Page */
|
||||
.settings-page {
|
||||
max-width: 1240px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
background: var(--panel);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Preview Switcher */
|
||||
.preview-switcher {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.preview-switcher button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preview-switcher button.active {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page-preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-preview.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 767px) {
|
||||
.shell-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.shell-nav {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.home-hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="shell-header">
|
||||
<a href="#" class="brand">Headquarter</a>
|
||||
<div class="header-actions">
|
||||
<a href="#" class="user-chip">User</a>
|
||||
<button class="ghost-button">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="shell-body">
|
||||
<aside class="shell-nav" aria-label="Primary navigation">
|
||||
<a href="#" class="nav-item nav-item-active">
|
||||
<span>🏠</span> Home
|
||||
<span class="nav-badge">3</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item">
|
||||
<span>📁</span> Projects
|
||||
</a>
|
||||
<a href="#" class="nav-item">
|
||||
<span>⚙️</span> Settings
|
||||
</a>
|
||||
|
||||
<div class="nav-divider"></div>
|
||||
<div class="nav-section-title">Live sessions</div>
|
||||
<a href="#" class="nav-item session-item">
|
||||
<span class="session-status running"></span>
|
||||
<span>Dev Environment</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item session-item">
|
||||
<span class="session-status running"></span>
|
||||
<span>Jupyter Lab</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item session-item">
|
||||
<span class="session-status"></span>
|
||||
<span>Code Server</span>
|
||||
</a>
|
||||
</aside>
|
||||
|
||||
<main class="shell-content">
|
||||
<!-- HOME PAGE PREVIEW -->
|
||||
<div id="home-preview" class="page-preview active">
|
||||
<section class="stack home-page">
|
||||
<header class="home-hero card">
|
||||
<div class="stack-sm">
|
||||
<p class="eyebrow">Workspace overview</p>
|
||||
<h1>Home</h1>
|
||||
<p class="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||
</div>
|
||||
<div class="home-hero-actions">
|
||||
<button class="primary-button">New Project</button>
|
||||
<button class="secondary-button">Settings</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="home-summary-grid">
|
||||
<article class="card home-summary-card">
|
||||
<p class="card-label">Open sessions</p>
|
||||
<p class="card-value">3</p>
|
||||
</article>
|
||||
<article class="card home-summary-card">
|
||||
<p class="card-label">Projects</p>
|
||||
<p class="card-value">5</p>
|
||||
</article>
|
||||
<article class="card home-summary-card">
|
||||
<p class="card-label">Repositories</p>
|
||||
<p class="card-value">12</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="card stack home-section">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Open sessions</p>
|
||||
<h2>3</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-session-grid">
|
||||
<article class="card session-card">
|
||||
<div class="stack-sm">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<h3>Dev Environment</h3>
|
||||
<span class="status-badge running">running</span>
|
||||
</div>
|
||||
<p class="muted">Acme Corp · main</p>
|
||||
<p class="muted">VS Code Server</p>
|
||||
</div>
|
||||
<div class="session-actions">
|
||||
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card session-card">
|
||||
<div class="stack-sm">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<h3>Jupyter Lab</h3>
|
||||
<span class="status-badge running">running</span>
|
||||
</div>
|
||||
<p class="muted">Data Science · experiments</p>
|
||||
<p class="muted">Jupyter Notebook</p>
|
||||
</div>
|
||||
<div class="session-actions">
|
||||
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card session-card">
|
||||
<div class="stack-sm">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<h3>Database Console</h3>
|
||||
<span class="status-badge building">building</span>
|
||||
</div>
|
||||
<p class="muted">Backend API · staging</p>
|
||||
<p class="muted">PostgreSQL Client</p>
|
||||
</div>
|
||||
<div class="session-actions">
|
||||
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stack home-section">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Available projects</p>
|
||||
<h2>5</h2>
|
||||
</div>
|
||||
<button class="secondary-button">View all</button>
|
||||
</div>
|
||||
<div class="home-project-grid">
|
||||
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||
<div class="stack-sm">
|
||||
<h3>Acme Corp</h3>
|
||||
<p class="muted">Main product development</p>
|
||||
</div>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||
</article>
|
||||
|
||||
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||
<div class="stack-sm">
|
||||
<h3>Data Science</h3>
|
||||
<p class="muted">ML experiments and notebooks</p>
|
||||
</div>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||
</article>
|
||||
|
||||
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||
<div class="stack-sm">
|
||||
<h3>Backend API</h3>
|
||||
<p class="muted">REST API services</p>
|
||||
</div>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stack home-section">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Quick create</p>
|
||||
<h2>Start a session</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form class="stack create-session-form">
|
||||
<div class="form-row">
|
||||
<label class="form-field">
|
||||
Project
|
||||
<select>
|
||||
<option>Select project...</option>
|
||||
<option>Acme Corp</option>
|
||||
<option>Data Science</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
Repository
|
||||
<select disabled>
|
||||
<option>Select repository...</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
Tool type
|
||||
<select>
|
||||
<option>Select tool...</option>
|
||||
<option>VS Code Server</option>
|
||||
<option>Jupyter Lab</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="form-field">
|
||||
Display name
|
||||
<input type="text" placeholder="My Development Environment">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button class="primary-button" type="submit">Create Session</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card stack home-section">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Recent sessions</p>
|
||||
<h2>2</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recent-sessions-list">
|
||||
<article class="recent-session-item">
|
||||
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||
<span class="recent-session-name">Old Dev Box</span>
|
||||
<span class="muted">Acme Corp · VS Code Server</span>
|
||||
</div>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||
</article>
|
||||
<article class="recent-session-item">
|
||||
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||
<span class="recent-session-name">ML Training</span>
|
||||
<span class="muted">Data Science · Jupyter Lab</span>
|
||||
</div>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- SETTINGS PAGE PREVIEW -->
|
||||
<div id="settings-preview" class="page-preview">
|
||||
<section class="stack settings-page">
|
||||
<header class="settings-header card stack-sm">
|
||||
<div>
|
||||
<p class="eyebrow">Configuration</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<p class="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
||||
</header>
|
||||
|
||||
<nav class="settings-tabs" aria-label="Settings sections">
|
||||
<a href="#" class="settings-tab active">General</a>
|
||||
<a href="#" class="settings-tab">SSH Keys</a>
|
||||
<a href="#" class="settings-tab">Tool Types</a>
|
||||
<a href="#" class="settings-tab">Tool Configs</a>
|
||||
</nav>
|
||||
|
||||
<div class="settings-panel card">
|
||||
<div class="stack">
|
||||
<h2>General</h2>
|
||||
<label class="form-field">
|
||||
Theme
|
||||
<select>
|
||||
<option>System</option>
|
||||
<option>Light</option>
|
||||
<option>Dark</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
Git user name
|
||||
<input type="text" placeholder="Your git commit name" value="John Doe">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
Git user email
|
||||
<input type="email" placeholder="your.email@example.com" value="john@example.com">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
Default editor
|
||||
<input type="text" placeholder="e.g., vscode, vim, cursor" value="vscode">
|
||||
</label>
|
||||
<div class="settings-actions">
|
||||
<button class="primary-button">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-switcher">
|
||||
<button class="active" onclick="showPage('home')">Home</button>
|
||||
<button onclick="showPage('settings')">Settings</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showPage(page) {
|
||||
document.querySelectorAll('.page-preview').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.preview-switcher button').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById(page + '-preview').classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import axios from "axios";
|
||||
import {
|
||||
createConfigFolder,
|
||||
deleteConfigFolder,
|
||||
@@ -7,25 +8,8 @@ import {
|
||||
updateConfigFolder,
|
||||
} from "../api/config_folders";
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
const mockPut = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
apiClient: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
interceptors: {
|
||||
response: {
|
||||
use: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
shouldSkipAuthRedirect: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("axios");
|
||||
const mockedAxios = vi.mocked(axios);
|
||||
|
||||
describe("config_folders API", () => {
|
||||
describe("listConfigFolders", () => {
|
||||
@@ -35,24 +19,43 @@ describe("config_folders API", () => {
|
||||
{
|
||||
id: "folder-1",
|
||||
name: "my-dotfiles",
|
||||
description: "My personal config files",
|
||||
mount_path: "/home/user",
|
||||
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
||||
project_overrides: {},
|
||||
files: {
|
||||
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"",
|
||||
},
|
||||
project_overrides: {
|
||||
"proj-1": {
|
||||
mount_path: "/workspace",
|
||||
files: { ".zshrc": "different content" },
|
||||
},
|
||||
},
|
||||
is_active: true,
|
||||
user_id: "user-1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
mockedAxios.get.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await listConfigFolders();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("my-dotfiles");
|
||||
expect(result[0].files).toEqual({ ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" });
|
||||
expect(mockGet).toHaveBeenCalledWith("/config-folders");
|
||||
expect(result[0].files).toEqual({
|
||||
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"",
|
||||
});
|
||||
expect(result[0].project_overrides).toEqual({
|
||||
"proj-1": {
|
||||
mount_path: "/workspace",
|
||||
files: { ".zshrc": "different content" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty array when no folders", async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await listConfigFolders();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,30 +63,29 @@ describe("config_folders API", () => {
|
||||
it("creates folder with files", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
id: "folder-new",
|
||||
name: "new-folder",
|
||||
mount_path: "/workspace",
|
||||
files: { ".env": "API_URL=http://localhost" },
|
||||
id: "new-folder",
|
||||
name: "my-configs",
|
||||
mount_path: "/home/user",
|
||||
files: { "test.txt": "hello" },
|
||||
is_active: true,
|
||||
user_id: "user-1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
mockedAxios.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await createConfigFolder({
|
||||
name: "new-folder",
|
||||
mount_path: "/workspace",
|
||||
files: { ".env": "API_URL=http://localhost" },
|
||||
name: "my-configs",
|
||||
mount_path: "/home/user",
|
||||
files: { "test.txt": "hello" },
|
||||
});
|
||||
|
||||
expect(result.name).toBe("new-folder");
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
expect(result.name).toBe("my-configs");
|
||||
expect(result.files).toEqual({ "test.txt": "hello" });
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
"/config-folders",
|
||||
expect.objectContaining({
|
||||
name: "new-folder",
|
||||
mount_path: "/workspace",
|
||||
name: "my-configs",
|
||||
mount_path: "/home/user",
|
||||
files: { "test.txt": "hello" },
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -94,38 +96,52 @@ describe("config_folders API", () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
id: "folder-1",
|
||||
name: "updated-folder",
|
||||
mount_path: "/home/user",
|
||||
files: { ".bashrc": "alias ll='ls -la'" },
|
||||
is_active: true,
|
||||
user_id: "user-1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
name: "updated-name",
|
||||
files: { "new.txt": "content" },
|
||||
},
|
||||
};
|
||||
mockPut.mockResolvedValue(mockResponse);
|
||||
mockedAxios.put.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await updateConfigFolder("folder-1", {
|
||||
files: { ".bashrc": "alias ll='ls -la'" },
|
||||
name: "updated-name",
|
||||
files: { "new.txt": "content" },
|
||||
});
|
||||
|
||||
expect(result.files).toEqual({ ".bashrc": "alias ll='ls -la'" });
|
||||
expect(mockPut).toHaveBeenCalledWith(
|
||||
expect(result.name).toBe("updated-name");
|
||||
expect(mockedAxios.put).toHaveBeenCalledWith(
|
||||
"/config-folders/folder-1",
|
||||
expect.objectContaining({
|
||||
files: { ".bashrc": "alias ll='ls -la'" },
|
||||
name: "updated-name",
|
||||
files: { "new.txt": "content" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("updates folder activation status", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
id: "folder-1",
|
||||
name: "my-configs",
|
||||
is_active: false,
|
||||
},
|
||||
};
|
||||
mockedAxios.put.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await updateConfigFolder("folder-1", {
|
||||
is_active: false,
|
||||
});
|
||||
|
||||
expect(result.is_active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteConfigFolder", () => {
|
||||
it("deletes folder", async () => {
|
||||
mockDelete.mockResolvedValue({ data: undefined });
|
||||
mockedAxios.delete.mockResolvedValue({ data: undefined });
|
||||
|
||||
await deleteConfigFolder("folder-1");
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith("/config-folders/folder-1");
|
||||
expect(mockedAxios.delete).toHaveBeenCalledWith("/config-folders/folder-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import axios from "axios";
|
||||
import {
|
||||
createToolType,
|
||||
deleteToolType,
|
||||
@@ -8,25 +9,8 @@ import {
|
||||
validateToolType,
|
||||
} from "../api/tool_types";
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
const mockPut = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
apiClient: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
interceptors: {
|
||||
response: {
|
||||
use: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
shouldSkipAuthRedirect: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("axios");
|
||||
const mockedAxios = vi.mocked(axios);
|
||||
|
||||
describe("tool_types API", () => {
|
||||
describe("listToolTypes", () => {
|
||||
@@ -44,13 +28,10 @@ describe("tool_types API", () => {
|
||||
timeout: 30,
|
||||
interval: 2,
|
||||
},
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
mockedAxios.get.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await listToolTypes();
|
||||
|
||||
@@ -72,13 +53,10 @@ describe("tool_types API", () => {
|
||||
definition_type: "compose",
|
||||
compose_template: "version: '3.8'",
|
||||
dockerfile_template: null,
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
mockedAxios.get.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await listToolTypes();
|
||||
|
||||
@@ -95,12 +73,9 @@ describe("tool_types API", () => {
|
||||
name: "docker-tool",
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM node:18",
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
mockedAxios.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await createToolType({
|
||||
name: "docker-tool",
|
||||
@@ -112,7 +87,7 @@ describe("tool_types API", () => {
|
||||
});
|
||||
|
||||
expect(result.definition_type).toBe("dockerfile");
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
"/tool-types",
|
||||
expect.objectContaining({
|
||||
definition_type: "dockerfile",
|
||||
@@ -131,12 +106,9 @@ describe("tool_types API", () => {
|
||||
timeout: 60,
|
||||
interval: 3,
|
||||
},
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
mockedAxios.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await createToolType({
|
||||
name: "probed-tool",
|
||||
@@ -160,25 +132,30 @@ describe("tool_types API", () => {
|
||||
});
|
||||
|
||||
describe("validateToolType", () => {
|
||||
it("validates tool type by id", async () => {
|
||||
it("validates compose template", async () => {
|
||||
const mockResponse = {
|
||||
data: { valid: true, errors: [] },
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
mockedAxios.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await validateToolType("type-1");
|
||||
const result = await validateToolType({
|
||||
definition_type: "compose",
|
||||
compose_template: "version: '3.8'",
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(mockGet).toHaveBeenCalledWith("/tool-types/type-1/validate");
|
||||
});
|
||||
|
||||
it("returns validation errors", async () => {
|
||||
const mockResponse = {
|
||||
data: { valid: false, errors: ["Invalid YAML"] },
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
mockedAxios.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await validateToolType("type-1");
|
||||
const result = await validateToolType({
|
||||
definition_type: "compose",
|
||||
compose_template: "invalid: yaml: [",
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Invalid YAML");
|
||||
@@ -193,12 +170,9 @@ describe("tool_types API", () => {
|
||||
name: "updated-tool",
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM python:3.11",
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
mockPut.mockResolvedValue(mockResponse);
|
||||
mockedAxios.put.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await updateToolType("type-1", {
|
||||
definition_type: "dockerfile",
|
||||
@@ -206,7 +180,7 @@ describe("tool_types API", () => {
|
||||
});
|
||||
|
||||
expect(result.definition_type).toBe("dockerfile");
|
||||
expect(mockPut).toHaveBeenCalledWith(
|
||||
expect(mockedAxios.put).toHaveBeenCalledWith(
|
||||
"/tool-types/type-1",
|
||||
expect.objectContaining({
|
||||
definition_type: "dockerfile",
|
||||
@@ -217,11 +191,11 @@ describe("tool_types API", () => {
|
||||
|
||||
describe("deleteToolType", () => {
|
||||
it("deletes tool type", async () => {
|
||||
mockDelete.mockResolvedValue({ data: undefined });
|
||||
mockedAxios.delete.mockResolvedValue({ data: undefined });
|
||||
|
||||
await deleteToolType("type-1");
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith("/tool-types/type-1");
|
||||
expect(mockedAxios.delete).toHaveBeenCalledWith("/tool-types/type-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,20 +10,22 @@ import { Icon } from "./icon";
|
||||
import type { IconName } from "../utils/icons";
|
||||
|
||||
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
||||
{ to: "/", label: "Home", icon: "dashboard" },
|
||||
{ to: "/", label: "Dashboard", icon: "dashboard" },
|
||||
{ to: "/sessions", label: "Sessions", icon: "terminal" },
|
||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
|
||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||
];
|
||||
|
||||
const SessionItem = ({ session }: { session: Session }) => {
|
||||
const isRunning = session.status === "running";
|
||||
|
||||
|
||||
return (
|
||||
<a
|
||||
href={session.url ?? `/projects/${session.project_id}`}
|
||||
target={session.url ? "_blank" : undefined}
|
||||
rel={session.url ? "noopener noreferrer" : undefined}
|
||||
href={session.url || "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nav-item session-item"
|
||||
title={`${session.display_name} (${session.status})`}
|
||||
>
|
||||
@@ -83,7 +85,7 @@ export const AppShell = () => {
|
||||
<div className="shell-body">
|
||||
<aside className="shell-nav" aria-label="Primary navigation">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isHome = item.to === "/";
|
||||
const isSessions = item.to === "/sessions";
|
||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||
return (
|
||||
<NavLink
|
||||
@@ -94,7 +96,7 @@ export const AppShell = () => {
|
||||
>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
{item.label}
|
||||
{isHome && activeCount > 0 && (
|
||||
{isSessions && activeCount > 0 && (
|
||||
<span className="nav-badge">{activeCount}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
@@ -104,7 +106,7 @@ export const AppShell = () => {
|
||||
{sessions.length > 0 && (
|
||||
<>
|
||||
<div className="nav-divider" />
|
||||
<div className="nav-section-title">Live sessions</div>
|
||||
<div className="nav-section-title">Sessions</div>
|
||||
{sessions.map((session) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
|
||||
@@ -1,81 +1,54 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HomePage } from "./dashboard";
|
||||
import { DashboardPage } from "./dashboard";
|
||||
|
||||
const mockDashboard = vi.fn();
|
||||
const mockSessions = vi.fn();
|
||||
const mockProjects = vi.fn();
|
||||
const mockRepos = vi.fn();
|
||||
const mockGet = vi.fn();
|
||||
|
||||
vi.mock("../api/dashboard", () => ({
|
||||
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
|
||||
getDashboardSummary: (...args: unknown[]) => mockGet(...args)
|
||||
}));
|
||||
|
||||
vi.mock("../api/sessions", () => ({
|
||||
getUserSessions: (...args: unknown[]) => mockSessions(...args),
|
||||
createInstance: vi.fn(),
|
||||
startInstance: vi.fn(),
|
||||
stopInstance: vi.fn(),
|
||||
deleteInstance: vi.fn(),
|
||||
recreateInstanceTunnel: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("../api/projects", () => ({
|
||||
listProjects: (...args: unknown[]) => mockProjects(...args)
|
||||
}));
|
||||
|
||||
vi.mock("../api/git_repositories", () => ({
|
||||
listRepositories: (...args: unknown[]) => mockRepos(...args)
|
||||
}));
|
||||
|
||||
vi.mock("../api/tool_types", () => ({
|
||||
listToolTypes: vi.fn().mockResolvedValue([])
|
||||
}));
|
||||
|
||||
describe("HomePage", () => {
|
||||
describe("DashboardPage", () => {
|
||||
beforeEach(() => {
|
||||
mockDashboard.mockReset();
|
||||
mockSessions.mockReset();
|
||||
mockProjects.mockReset();
|
||||
mockRepos.mockReset();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
it("shows overview sections", async () => {
|
||||
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
|
||||
mockSessions.mockResolvedValue([]);
|
||||
mockProjects.mockResolvedValue([]);
|
||||
mockRepos.mockResolvedValue([]);
|
||||
it("shows loading then empty state when summary has no data", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
projects: 0,
|
||||
repositories: 0,
|
||||
sshKeys: 0,
|
||||
recentActivity: []
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Available projects")).toBeInTheDocument();
|
||||
expect(screen.getByText("No activity yet")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows retry action when home load fails", async () => {
|
||||
mockDashboard.mockRejectedValueOnce(new Error("failed"));
|
||||
mockSessions.mockRejectedValueOnce(new Error("failed"));
|
||||
mockProjects.mockRejectedValueOnce(new Error("failed"));
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
|
||||
it("shows retry action when summary request fails", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("failed"));
|
||||
mockGet.mockResolvedValueOnce({
|
||||
projects: 2,
|
||||
repositories: 5,
|
||||
sshKeys: 1,
|
||||
recentActivity: ["Created repo"]
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Dashboard is unavailable")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,338 +1,83 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
import type { Project } from "../types";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
|
||||
const summaryCards = [
|
||||
{ label: "Open sessions", key: "openSessions" },
|
||||
const CARDS = [
|
||||
{ label: "Projects", key: "projects" },
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
{ label: "SSH Keys", key: "sshKeys" }
|
||||
] as const;
|
||||
|
||||
type SessionView = SessionApi;
|
||||
type DashboardStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const HomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||
export const DashboardPage = () => {
|
||||
const [status, setStatus] = useState<DashboardStatus>("loading");
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [selectedRepo, setSelectedRepo] = useState("");
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
const loadHome = useCallback(async () => {
|
||||
const loadSummary = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
|
||||
getDashboardSummary(),
|
||||
getUserSessions(),
|
||||
listProjects(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
setSummary(dashboard);
|
||||
setSessions(sessionData as SessionView[]);
|
||||
setProjects(projectData);
|
||||
setToolTypes(toolTypeData);
|
||||
const data = await getDashboardSummary();
|
||||
setSummary(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setSummary(null);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
void loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
|
||||
const activeSessions = useMemo(
|
||||
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
|
||||
[safeSessions]
|
||||
);
|
||||
|
||||
const recentSessions = useMemo(
|
||||
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
|
||||
[safeSessions]
|
||||
);
|
||||
|
||||
const handleCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
||||
|
||||
setSaveState("saving");
|
||||
try {
|
||||
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
|
||||
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setDisplayName("");
|
||||
setSelectedProject("");
|
||||
setSelectedRepo("");
|
||||
setSelectedToolType("");
|
||||
setSaveState("idle");
|
||||
await loadHome();
|
||||
} catch {
|
||||
setSaveState("error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = (session: SessionView) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
if (session.tool_type_interfaces.includes("terminal")) {
|
||||
navigate(`/instances/${session.id}/terminal`);
|
||||
return;
|
||||
}
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
};
|
||||
|
||||
const handleStop = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecreateTunnel = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
const cards = useMemo(() => CARDS, []);
|
||||
const isEmpty =
|
||||
status === "ready" &&
|
||||
summary !== null &&
|
||||
summary.projects === 0 &&
|
||||
summary.repositories === 0 &&
|
||||
summary.sshKeys === 0 &&
|
||||
summary.recentActivity.length === 0;
|
||||
|
||||
return (
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
<div className="stack-sm">
|
||||
<p className="eyebrow">Workspace overview</p>
|
||||
<h1>Home</h1>
|
||||
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||
</div>
|
||||
<div className="home-hero-actions">
|
||||
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
|
||||
</div>
|
||||
</header>
|
||||
<section className="stack">
|
||||
<h1>Dashboard</h1>
|
||||
<p className="muted">Your workspace overview will appear here.</p>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading overview...</p>}
|
||||
{status === "loading" && <p className="muted">Loading dashboard...</p>}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Unable to load your workspace overview.</p>
|
||||
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
|
||||
<p>Dashboard is unavailable</p>
|
||||
<button className="secondary-button" onClick={() => void loadSummary()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && summary && (
|
||||
<>
|
||||
<div className="home-summary-grid">
|
||||
{summaryCards.map((card) => (
|
||||
<article className="card home-summary-card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">
|
||||
{card.key === "openSessions"
|
||||
? activeSessions.length
|
||||
: card.key === "projects"
|
||||
? summary.projects
|
||||
: summary.repositories}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="card-grid">
|
||||
{cards.map((card) => (
|
||||
<article className="card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">{summary ? String(summary[card.key]) : "-"}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Open sessions</p>
|
||||
<h2>{activeSessions.length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{activeSessions.length === 0 ? (
|
||||
<p className="muted">No active sessions right now.</p>
|
||||
) : (
|
||||
<div className="home-session-grid">
|
||||
{activeSessions.map((session) => (
|
||||
<article className="card session-card" key={session.id}>
|
||||
<div className="stack-sm">
|
||||
<div className="row row-tight">
|
||||
<h3>{session.display_name}</h3>
|
||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||
</div>
|
||||
<p className="muted">{session.project_name} · {session.repository_name}</p>
|
||||
<p className="muted">{session.tool_type_name}</p>
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Tunnel
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{isEmpty && <p className="muted">No activity yet</p>}
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Available projects</p>
|
||||
<h2>{projects.length}</h2>
|
||||
</div>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<p className="muted">No projects yet.</p>
|
||||
) : (
|
||||
<div className="home-project-grid">
|
||||
{projects.map((project) => (
|
||||
<article className="card project-card home-project-card" key={project.id}>
|
||||
<div className="stack-sm">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
|
||||
Open Workspace
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Quick create</p>
|
||||
<h2>Start a session</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form className="stack create-session-form" onSubmit={handleCreate}>
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Project
|
||||
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
|
||||
<option value="">Select repository...</option>
|
||||
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Tool type
|
||||
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
|
||||
<option value="">Select tool...</option>
|
||||
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Display name
|
||||
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
|
||||
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
|
||||
</button>
|
||||
{saveState === "error" && <span className="error-text">Failed to create session</span>}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent sessions</p>
|
||||
<h2>{recentSessions.length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-sessions-list">
|
||||
{recentSessions.map((session) => (
|
||||
<article className="recent-session-item" key={session.id}>
|
||||
<div className="recent-session-info">
|
||||
<span className="recent-session-name">{session.display_name}</span>
|
||||
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
|
||||
</div>
|
||||
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="quick-actions">
|
||||
<button className="primary-button" type="button">
|
||||
<Icon name="add" size="sm" />
|
||||
New Project
|
||||
</button>
|
||||
<button className="secondary-button" type="button">
|
||||
<Icon name="add" size="sm" />
|
||||
Add Repository
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { HomePage as DashboardPage };
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProjectsPage } from "./projects";
|
||||
@@ -30,21 +29,13 @@ afterEach(() => {
|
||||
describe("ProjectsPage", () => {
|
||||
it("renders loading state initially", () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders project list after loading", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
@@ -55,11 +46,7 @@ describe("ProjectsPage", () => {
|
||||
|
||||
it("renders empty state when no projects", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -68,11 +55,7 @@ describe("ProjectsPage", () => {
|
||||
|
||||
it("renders error state with retry button", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
||||
@@ -84,11 +67,7 @@ describe("ProjectsPage", () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -117,11 +96,7 @@ describe("ProjectsPage", () => {
|
||||
it("shows validation error when name is empty", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -137,11 +112,7 @@ describe("ProjectsPage", () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
@@ -170,11 +141,7 @@ describe("ProjectsPage", () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
|
||||
@@ -1,33 +1,17 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||
|
||||
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
type SettingsStatus = "loading" | "ready" | "error";
|
||||
|
||||
const TABS = [
|
||||
{ label: "General", path: "general" },
|
||||
{ label: "SSH Keys", path: "ssh-keys" },
|
||||
{ label: "Tool Types", path: "tool-types" },
|
||||
{ label: "Tool Configs", path: "tool-configs" },
|
||||
] as const;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
];
|
||||
|
||||
type SettingsOutletContext = {
|
||||
config: UserConfig;
|
||||
handleChange: (key: keyof UserConfigUpdate, value: string | null) => void;
|
||||
handleSave: () => Promise<void>;
|
||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||
};
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation();
|
||||
const [status, setStatus] = useState<SettingsStatus>("loading");
|
||||
const [config, setConfig] = useState<UserConfig>({
|
||||
theme: "system",
|
||||
@@ -66,15 +50,21 @@ export const SettingsPage = () => {
|
||||
git_user_name: config.git_user_name,
|
||||
git_user_email: config.git_user_email,
|
||||
};
|
||||
console.log("Sending update:", update);
|
||||
const updated = await updateUserConfig(update);
|
||||
console.log("Received response:", updated);
|
||||
setConfig(updated);
|
||||
setSaveStatus("saved");
|
||||
if (updated.theme === "system") {
|
||||
|
||||
// Apply theme immediately
|
||||
const theme = updated.theme ?? "system";
|
||||
if (theme === "system") {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", updated.theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
window.setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
|
||||
setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
@@ -96,71 +86,81 @@ export const SettingsPage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const parts = location.pathname.split("/").filter(Boolean);
|
||||
const activePath = location.pathname.endsWith("/settings") ? "general" : (parts[parts.length - 1] ?? "general");
|
||||
|
||||
return (
|
||||
<section className="stack settings-page">
|
||||
<header className="settings-header card stack-sm">
|
||||
<div>
|
||||
<p className="eyebrow">Configuration</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
||||
</header>
|
||||
|
||||
<nav className="settings-tabs" aria-label="Settings sections">
|
||||
{TABS.map((tab) => (
|
||||
<Link
|
||||
key={tab.path}
|
||||
className={`settings-tab ${activePath === tab.path ? "active" : ""}`}
|
||||
to={tab.path === "general" ? "/settings" : `/settings/${tab.path}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="settings-panel card">
|
||||
<Outlet context={{ config, handleChange, handleSave, saveStatus }} />
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const GeneralSettingsTab = () => {
|
||||
const { config, handleChange, handleSave, saveStatus } = useOutletContext<SettingsOutletContext>();
|
||||
<div className="card stack">
|
||||
<h2>Appearance</h2>
|
||||
<label className="form-field">
|
||||
Theme
|
||||
<select
|
||||
value={config.theme}
|
||||
onChange={(e) => handleChange("theme", e.target.value)}
|
||||
>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card stack">
|
||||
<h2>Git Identity</h2>
|
||||
<label className="form-field">
|
||||
User Name
|
||||
<input
|
||||
type="text"
|
||||
value={config.git_user_name ?? ""}
|
||||
onChange={(e) => handleChange("git_user_name", e.target.value || null)}
|
||||
placeholder="Your git commit name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
User Email
|
||||
<input
|
||||
type="email"
|
||||
value={config.git_user_email ?? ""}
|
||||
onChange={(e) => handleChange("git_user_email", e.target.value || null)}
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card stack">
|
||||
<h2>Editor</h2>
|
||||
<label className="form-field">
|
||||
Default Editor
|
||||
<input
|
||||
type="text"
|
||||
value={config.default_editor ?? ""}
|
||||
onChange={(e) => handleChange("default_editor", e.target.value || null)}
|
||||
placeholder="e.g., vscode, vim, cursor"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<h2>General</h2>
|
||||
<label className="form-field">
|
||||
Theme
|
||||
<select value={config.theme} onChange={(e) => handleChange("theme", e.target.value)}>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user name
|
||||
<input type="text" value={config.git_user_name ?? ""} onChange={(e) => handleChange("git_user_name", e.target.value || null)} placeholder="Your git commit name" />
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user email
|
||||
<input type="email" value={config.git_user_email ?? ""} onChange={(e) => handleChange("git_user_email", e.target.value || null)} placeholder="your.email@example.com" />
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Default editor
|
||||
<input type="text" value={config.default_editor ?? ""} onChange={(e) => handleChange("default_editor", e.target.value || null)} placeholder="e.g., vscode, vim, cursor" />
|
||||
</label>
|
||||
<div className="settings-actions">
|
||||
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
||||
{saveStatus === "saving" ? <><Icon name="loading" size="sm" /> Saving...</> : <><Icon name="save" size="sm" /> Save Settings</>}
|
||||
{saveStatus === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
||||
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
export const SSHKeysPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -63,15 +61,7 @@ export const SSHKeysPage = () => {
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>SSH Keys</h1>
|
||||
</div>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||
Back to settings
|
||||
</button>
|
||||
</div>
|
||||
<h1>SSH Keys</h1>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Icon } from "../components/icon";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
type ConfigStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const ToolConfigsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<ConfigStatus>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||
@@ -137,13 +135,7 @@ export const ToolConfigsPage = () => {
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||
Back to settings
|
||||
</button>
|
||||
<h1>Tool Configurations</h1>
|
||||
<p className="muted">
|
||||
Manage environment variables and configuration files for your tools
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
createToolType,
|
||||
@@ -16,7 +15,6 @@ type ToolTypesStatus = "loading" | "ready" | "error";
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
|
||||
export const ToolTypesPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<ToolTypesStatus>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
@@ -167,18 +165,12 @@ export const ToolTypesPage = () => {
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header" style={{ marginBottom: "1rem" }}>
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Tool Types</h1>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
|
||||
<button onClick={openCreate}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<h1>Tool Types</h1>
|
||||
<button onClick={openCreate}>
|
||||
<Icon name="add" size="sm" />
|
||||
Create Tool Type
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolTypes.length === 0 ? (
|
||||
|
||||
@@ -18,13 +18,10 @@ const mockToolTypes = [
|
||||
definition_type: "compose",
|
||||
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
|
||||
dockerfile_template: null,
|
||||
build_context: null,
|
||||
readiness_probe: null,
|
||||
required_variables: ["REPO_PATH"],
|
||||
is_builtin: true,
|
||||
created_by_id: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "type-2",
|
||||
@@ -37,7 +34,6 @@ const mockToolTypes = [
|
||||
definition_type: "dockerfile",
|
||||
compose_template: null,
|
||||
dockerfile_template: "FROM python:3.11",
|
||||
build_context: null,
|
||||
readiness_probe: {
|
||||
command: "python --version",
|
||||
timeout: 30,
|
||||
@@ -46,8 +42,6 @@ const mockToolTypes = [
|
||||
required_variables: [],
|
||||
is_builtin: false,
|
||||
created_by_id: "user-1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -55,7 +49,6 @@ const mockConfigs = [
|
||||
{
|
||||
id: "config-1",
|
||||
tool_type_id: "type-1",
|
||||
project_id: null,
|
||||
key: "OPENAI_API_KEY",
|
||||
value: "sk-test123",
|
||||
config_type: "env",
|
||||
@@ -69,11 +62,9 @@ const mockConfigs = [
|
||||
{
|
||||
id: "config-2",
|
||||
tool_type_id: "type-2",
|
||||
project_id: null,
|
||||
key: "advanced-config",
|
||||
value: "test-value",
|
||||
config_type: "env",
|
||||
file_path: null,
|
||||
port_override: 9090,
|
||||
start_command: "python app.py",
|
||||
working_directory: "/app",
|
||||
@@ -85,19 +76,15 @@ const mockConfigs = [
|
||||
const mockFolders = [
|
||||
{
|
||||
id: "folder-1",
|
||||
user_id: "user-1",
|
||||
name: "my-dotfiles",
|
||||
description: "My personal config files",
|
||||
mount_path: "/home/user",
|
||||
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
||||
project_overrides: {},
|
||||
is_active: true,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "folder-2",
|
||||
user_id: "user-1",
|
||||
name: "project-configs",
|
||||
description: "Project specific configs",
|
||||
mount_path: "/workspace",
|
||||
@@ -109,8 +96,6 @@ const mockFolders = [
|
||||
},
|
||||
},
|
||||
is_active: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -130,9 +115,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("renders tool types tab by default", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -143,9 +128,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("switches to configs tab", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -162,9 +147,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("switches to folders tab", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -181,9 +166,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("opens tool type creation form", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -193,15 +178,15 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/display name/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates tool type with compose definition", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -211,15 +196,12 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||
fireEvent.change(screen.getByLabelText(/name/i), {
|
||||
target: { value: "new-tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||
fireEvent.change(screen.getByLabelText(/display name/i), {
|
||||
target: { value: "New Tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||
target: { value: "8080" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/compose template/i), {
|
||||
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
|
||||
});
|
||||
@@ -236,13 +218,14 @@ describe("ToolWorkshopPage", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("creates tool type with dockerfile definition", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -252,22 +235,17 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||
fireEvent.change(screen.getByLabelText(/name/i), {
|
||||
target: { value: "docker-tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||
fireEvent.change(screen.getByLabelText(/display name/i), {
|
||||
target: { value: "Docker Tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||
target: { value: "3000" },
|
||||
});
|
||||
|
||||
// Switch to dockerfile
|
||||
fireEvent.change(screen.getByLabelText("Definition Type"), {
|
||||
target: { value: "dockerfile" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText(/dockerfile/i));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
|
||||
fireEvent.change(screen.getByLabelText(/dockerfile template/i), {
|
||||
target: { value: "FROM python:3.11\\nRUN pip install flask" },
|
||||
});
|
||||
|
||||
@@ -285,9 +263,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("shows readiness probe fields", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -297,15 +275,20 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/interval/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/readiness command/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/timeout/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/interval/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens config creation form", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({
|
||||
tool_type_id: "type-1",
|
||||
suggested_configs: [],
|
||||
port_override: null,
|
||||
});
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -326,10 +309,15 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("creates config with advanced fields", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({
|
||||
tool_type_id: "type-1",
|
||||
suggested_configs: [],
|
||||
port_override: null,
|
||||
});
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -358,7 +346,7 @@ describe("ToolWorkshopPage", () => {
|
||||
target: { value: "python app.py" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add$/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
@@ -374,9 +362,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("opens folder creation form", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -392,15 +380,15 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||
|
||||
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/mount path/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates config folder successfully", async () => {
|
||||
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -416,10 +404,10 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||
fireEvent.change(screen.getByLabelText(/name/i), {
|
||||
target: { value: "new-folder" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Mount Path *"), {
|
||||
fireEvent.change(screen.getByLabelText(/mount path/i), {
|
||||
target: { value: "/home/dev" },
|
||||
});
|
||||
|
||||
@@ -437,9 +425,9 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("shows folder active/inactive status", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -453,8 +441,46 @@ describe("ToolWorkshopPage", () => {
|
||||
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that active folder shows Active badge
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
// Check that active folder is marked
|
||||
const activeFolder = screen.getByText("my-dotfiles").closest("[data-testid='folder-item']") ||
|
||||
screen.getByText("my-dotfiles").parentElement;
|
||||
expect(activeFolder?.textContent).toContain("active");
|
||||
});
|
||||
|
||||
it("validates tool type before creation", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
const validateMock = vi.spyOn(toolTypesApi, "validateToolType").mockResolvedValue({
|
||||
valid: false,
|
||||
errors: ["Invalid YAML syntax"],
|
||||
});
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/name/i), {
|
||||
target: { value: "bad-tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/display name/i), {
|
||||
target: { value: "Bad Tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/compose template/i), {
|
||||
target: { value: "invalid: yaml: [" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /validate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(validateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/invalid yaml syntax/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles error state gracefully", async () => {
|
||||
@@ -474,13 +500,13 @@ describe("ToolWorkshopPage", () => {
|
||||
it("retries loading after error", async () => {
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
.mockResolvedValueOnce(mockToolTypes);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs")
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
.mockResolvedValueOnce(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders")
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
.mockResolvedValueOnce(mockFolders);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -497,10 +523,10 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
it("deletes tool type successfully", async () => {
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
||||
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
@@ -509,14 +535,19 @@ describe("ToolWorkshopPage", () => {
|
||||
});
|
||||
|
||||
// Find and click delete button for custom tool (not built-in)
|
||||
const customToolCard = screen.getByText("Custom Tool").closest(".card") ||
|
||||
const customToolCard = screen.getByText("Custom Tool").closest("[data-testid='tool-type-item']") ||
|
||||
screen.getByText("Custom Tool").parentElement;
|
||||
if (customToolCard) {
|
||||
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
|
||||
if (deleteButton) {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
// Confirm deletion
|
||||
const confirmButton = screen.queryByRole("button", { name: /confirm/i });
|
||||
if (confirmButton) {
|
||||
fireEvent.click(confirmButton);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteMock).toHaveBeenCalledWith("type-2");
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
deleteToolType,
|
||||
listToolTypes,
|
||||
updateToolType,
|
||||
validateToolType,
|
||||
type CreateToolTypeRequest,
|
||||
type ReadinessProbe,
|
||||
type ToolType,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import {
|
||||
createToolConfig,
|
||||
deleteToolConfig,
|
||||
getToolConfigDefaults,
|
||||
listToolConfigs,
|
||||
updateToolConfig,
|
||||
type CreateToolConfigRequest,
|
||||
@@ -603,9 +605,8 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="readiness-command">Readiness Probe Command</label>
|
||||
<label>Readiness Probe Command</label>
|
||||
<input
|
||||
id="readiness-command"
|
||||
type="text"
|
||||
value={toolTypeForm.readiness_command}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value })}
|
||||
@@ -616,9 +617,8 @@ export const ToolWorkshopPage = () => {
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="readiness-timeout">Timeout (seconds)</label>
|
||||
<label>Timeout (seconds)</label>
|
||||
<input
|
||||
id="readiness-timeout"
|
||||
type="number"
|
||||
value={toolTypeForm.readiness_timeout}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value })}
|
||||
@@ -626,9 +626,8 @@ export const ToolWorkshopPage = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="readiness-interval">Interval (seconds)</label>
|
||||
<label>Interval (seconds)</label>
|
||||
<input
|
||||
id="readiness-interval"
|
||||
type="number"
|
||||
value={toolTypeForm.readiness_interval}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value })}
|
||||
@@ -765,9 +764,8 @@ export const ToolWorkshopPage = () => {
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-value">Value</label>
|
||||
<label>Value</label>
|
||||
<textarea
|
||||
id="config-value"
|
||||
value={configForm.value}
|
||||
onChange={(e) => setConfigForm({ ...configForm, value: e.target.value })}
|
||||
placeholder={configForm.config_type === "env" ? "Enter value..." : "Enter file contents..."}
|
||||
@@ -779,9 +777,8 @@ export const ToolWorkshopPage = () => {
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="config-port-override">Port Override</label>
|
||||
<label>Port Override</label>
|
||||
<input
|
||||
id="config-port-override"
|
||||
type="number"
|
||||
value={configForm.port_override}
|
||||
onChange={(e) => setConfigForm({ ...configForm, port_override: e.target.value })}
|
||||
@@ -790,9 +787,8 @@ export const ToolWorkshopPage = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="config-start-command">Start Command</label>
|
||||
<label>Start Command</label>
|
||||
<input
|
||||
id="config-start-command"
|
||||
type="text"
|
||||
value={configForm.start_command}
|
||||
onChange={(e) => setConfigForm({ ...configForm, start_command: e.target.value })}
|
||||
@@ -922,9 +918,8 @@ export const ToolWorkshopPage = () => {
|
||||
<h3>{selectedFolder ? "Edit" : "Create"} Config Folder</h3>
|
||||
<form onSubmit={handleFolderSubmit} className="stack">
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-name">Name *</label>
|
||||
<label>Name *</label>
|
||||
<input
|
||||
id="folder-name"
|
||||
type="text"
|
||||
value={folderForm.name}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, name: e.target.value })}
|
||||
@@ -935,9 +930,8 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-description">Description</label>
|
||||
<label>Description</label>
|
||||
<input
|
||||
id="folder-description"
|
||||
type="text"
|
||||
value={folderForm.description}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, description: e.target.value })}
|
||||
@@ -947,9 +941,8 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-mount-path">Mount Path *</label>
|
||||
<label>Mount Path *</label>
|
||||
<input
|
||||
id="folder-mount-path"
|
||||
type="text"
|
||||
value={folderForm.mount_path}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, mount_path: e.target.value })}
|
||||
@@ -960,9 +953,8 @@ export const ToolWorkshopPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-files">Files (JSON object)</label>
|
||||
<label>Files (JSON object)</label>
|
||||
<textarea
|
||||
id="folder-files"
|
||||
value={folderForm.files_json}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, files_json: e.target.value })}
|
||||
placeholder='{".zshrc": "export ZSH=...", ".gitconfig": "[user]\\nname = ..."}'
|
||||
|
||||
+10
-18
@@ -2,29 +2,24 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "./components/app-shell";
|
||||
import { ProtectedRoute } from "./components/protected-route";
|
||||
import { HomePage } from "./pages/dashboard";
|
||||
import { DashboardPage } from "./pages/dashboard";
|
||||
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
|
||||
import { SessionsPage } from "./pages/sessions";
|
||||
import { ProfilePage } from "./pages/profile";
|
||||
import { ProjectsPage } from "./pages/projects";
|
||||
import { GitRepositoriesPage } from "./pages/git-repositories";
|
||||
import { GitHistoryPage } from "./pages/git-history";
|
||||
import { ProjectSettingsPage } from "./pages/project-settings";
|
||||
import { RepoWorkspace } from "./pages/repo-workspace";
|
||||
import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||
import { SettingsPage } from "./pages/settings";
|
||||
import { TerminalPage } from "./pages/terminal";
|
||||
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||
import { ToolConfigsPage } from "./pages/tool-configs";
|
||||
import { ToolTypesPage } from "./pages/tool-types";
|
||||
|
||||
export const AppRouter = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginRedirectPage />} />
|
||||
<Route path="/sessions" element={<Navigate to="/" replace />} />
|
||||
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
|
||||
<Route path="/tool-types" element={<Navigate to="/settings/tool-types" replace />} />
|
||||
<Route path="/tool-configs" element={<Navigate to="/settings/tool-configs" replace />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
@@ -33,22 +28,19 @@ export const AppRouter = () => {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="sessions" element={<SessionsPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
||||
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
||||
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
||||
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="settings" element={<SettingsPage />}>
|
||||
<Route index element={<Navigate to="general" replace />} />
|
||||
<Route path="general" element={<GeneralSettingsTab />} />
|
||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||
<Route path="tool-types" element={<ToolTypesPage />} />
|
||||
<Route path="tool-configs" element={<ToolConfigsPage />} />
|
||||
<Route path="*" element={<Navigate to="general" replace />} />
|
||||
</Route>
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||
<Route path="tool-types" element={<Navigate to="/tool-workshop" replace />} />
|
||||
<Route path="tool-configs" element={<Navigate to="/tool-workshop" replace />} />
|
||||
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
||||
</Route>
|
||||
<Route path="/404" element={<NotFoundPage />} />
|
||||
|
||||
+10
-148
@@ -1,6 +1,6 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
--bg: #f4f1ea;
|
||||
--panel: #fffef9;
|
||||
--ink: #1d1d1b;
|
||||
@@ -8,17 +8,6 @@
|
||||
--brand: #275d4b;
|
||||
--brand-strong: #154236;
|
||||
--border: #d8d0c5;
|
||||
--primary: #275d4b;
|
||||
--primary-fg: #fffef9;
|
||||
--color-primary: #275d4b;
|
||||
--success: #2f8f62;
|
||||
--success-light: rgba(47, 143, 98, 0.14);
|
||||
--warning: #c08a1e;
|
||||
--warning-light: rgba(192, 138, 30, 0.14);
|
||||
--danger: #b94a3c;
|
||||
--danger-light: rgba(185, 74, 60, 0.14);
|
||||
--info: #4f7fb8;
|
||||
--info-light: rgba(79, 127, 184, 0.14);
|
||||
|
||||
/* Spacing Scale (4px base) */
|
||||
--space-1: 0.25rem;
|
||||
@@ -47,16 +36,13 @@
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #171613;
|
||||
--panel: #22201d;
|
||||
--ink: #ece7df;
|
||||
--muted: #a59d92;
|
||||
--brand: #5fa889;
|
||||
--brand-strong: #4d9175;
|
||||
--border: #39342d;
|
||||
--primary: #5fa889;
|
||||
--primary-fg: #171613;
|
||||
--color-primary: #5fa889;
|
||||
--bg: #1a1a18;
|
||||
--panel: #252522;
|
||||
--ink: #e8e6e1;
|
||||
--muted: #a39e96;
|
||||
--brand: #4a9e7f;
|
||||
--brand-strong: #3d8a6e;
|
||||
--border: #3d3d38;
|
||||
--success: #22c55e;
|
||||
--success-light: rgba(34, 197, 94, 0.15);
|
||||
--warning: #f59e0b;
|
||||
@@ -98,12 +84,12 @@ a {
|
||||
align-items: center;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .shell-header {
|
||||
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||
background: rgba(37, 37, 34, 0.85);
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -129,7 +115,6 @@ a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
background: color-mix(in srgb, var(--panel) 65%, transparent);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
@@ -148,134 +133,11 @@ a {
|
||||
color: #f7fff7;
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.shell-content {
|
||||
padding: 1.25rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.home-page,
|
||||
.settings-page {
|
||||
max-width: 1240px;
|
||||
}
|
||||
|
||||
.home-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.home-hero-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.home-summary-grid,
|
||||
.home-project-grid,
|
||||
.home-session-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.home-summary-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.home-project-grid,
|
||||
.home-session-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.home-section h2,
|
||||
.settings-header h1,
|
||||
.settings-panel h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.home-section h3,
|
||||
.home-section p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-tight {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-actions,
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: 0.42rem 0.7rem;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.session-card,
|
||||
.project-card,
|
||||
.recent-session-item {
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* Responsive Shell */
|
||||
@media (max-width: 767px) {
|
||||
.shell-body {
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"isolatedModules": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-24
|
||||
@@ -1,45 +0,0 @@
|
||||
## Context
|
||||
|
||||
The current system uses `config_folders` with a flat `files` JSONB and an `is_active` flag for auto-mounting at tool launch time. This design is inflexible: only one folder can be active, there's no ordering of includes, no explicit per-tool-instance selection, and mount definitions are mixed with file contents in a single blob.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Provide structured config profiles with named collections of mounts and includes
|
||||
- Support ordered include lists so profiles can reference other profiles in sequence
|
||||
- Allow per-tool-instance profile selection with fallback to user/tool-type defaults
|
||||
- Remove implicit auto-mounting behavior at launch time
|
||||
- Maintain backward compatibility for existing `config_folders` data during migration
|
||||
|
||||
**Non-Goals:**
|
||||
- Frontend UI for profile management (separate change)
|
||||
- Real-time profile switching on running instances
|
||||
- Profile versioning or history
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. New `config_profiles` table replaces the semantic role of `config_folders`
|
||||
- Rationale: A profile is a higher-level concept than a folder; it includes mounts, includes, and metadata
|
||||
- `config_folders` remains for data migration but is no longer used for auto-mounting
|
||||
|
||||
### 2. `config_includes` provides ordered many-to-many self-reference on `config_profiles`
|
||||
- Rationale: Profiles need to include other profiles (e.g., a "base" profile included by "project-specific")
|
||||
- `order_index` column controls application order
|
||||
|
||||
### 3. `config_mounts` stores individual mount/file entries
|
||||
- Rationale: Normalizing mounts allows querying, ordering, and validation per mount
|
||||
- Each mount has a `mount_path`, optional `content` text, and optional `source_profile_id` for transitive includes
|
||||
|
||||
### 4. Default profile stored on `user_configs.config` JSONB
|
||||
- Rationale: Avoids schema changes to `users`; the existing `user_configs` table already stores per-user JSON
|
||||
- Key: `default_profile_id` (global default) and `default_profiles` map for per-tool-type defaults
|
||||
|
||||
### 5. `tool_instances.selected_profile_id` for explicit selection
|
||||
- Rationale: Clear, direct foreign key; nullable to allow fallback to defaults
|
||||
- Null means "use default resolution"
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] Existing `config_folders` data becomes orphaned if not migrated → Mitigation: keep table, stop auto-mount behavior only
|
||||
- [Risk] Profile include cycles could cause infinite loops → Mitigation: validate at write time, detect cycles in include graph
|
||||
- [Risk] Multiple includes with overlapping mount paths → Mitigation: last-include-wins based on order_index
|
||||
@@ -1,30 +0,0 @@
|
||||
## Why
|
||||
|
||||
The current `config_folders` table provides basic file mounting but lacks structured profile management, ordering, and per-tool-instance selection. We need a proper config profile system that supports ordered includes, mount/file definitions, default selection, and explicit profile assignment per tool instance.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `ConfigProfile` model to replace the legacy `config_folders` concept with structured profiles
|
||||
- Add `ConfigInclude` model for ordered include lists within profiles
|
||||
- Add `ConfigMount` model for mount/file definitions (replacing the flat `files` JSONB on `config_folders`)
|
||||
- Add default profile selection per user and tool type
|
||||
- Add `selected_profile_id` to `ToolInstance` for per-instance profile selection
|
||||
- Remove launch-time reliance on legacy active config folder auto-mounting (mark `config_folders.is_active` as deprecated, stop auto-mounting at launch)
|
||||
- Create database migrations for all new tables
|
||||
- **BREAKING**: Legacy `config_folders` auto-mounting behavior will be removed; tool instances must explicitly select a profile
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `config-profile-management`: CRUD operations for config profiles, includes, and mounts
|
||||
- `tool-instance-profile-selection`: Assign and switch config profiles per tool instance
|
||||
|
||||
### Modified Capabilities
|
||||
- `tool-instance-launch`: Change launch behavior to use explicit profile selection instead of auto-mounting active config folder
|
||||
|
||||
## Impact
|
||||
|
||||
- New database tables: `config_profiles`, `config_includes`, `config_mounts`
|
||||
- Modified tables: `tool_instances` (add `selected_profile_id`), `users` or `user_configs` (add default profile selection)
|
||||
- API endpoints for profile management and instance profile assignment
|
||||
- Tool launch logic changes (remove auto-mount, use explicit profile)
|
||||
@@ -1,29 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: User can create config profiles
|
||||
The system SHALL allow users to create named config profiles containing mounts and includes.
|
||||
|
||||
#### Scenario: Successful profile creation
|
||||
- **WHEN** user creates a profile with name, description, and mount list
|
||||
- **THEN** the profile is stored with a unique ID and associated mounts
|
||||
|
||||
### Requirement: Profile includes are ordered
|
||||
The system SHALL support ordered includes where profiles can reference other profiles with a defined application sequence.
|
||||
|
||||
#### Scenario: Include with order
|
||||
- **WHEN** user adds an include to a profile with order_index 1
|
||||
- **THEN** the included profile's mounts are applied after order_index 0 includes
|
||||
|
||||
### Requirement: Config mounts define files and paths
|
||||
The system SHALL store individual mount entries with mount_path, optional content, and optional source profile reference.
|
||||
|
||||
#### Scenario: Add mount to profile
|
||||
- **WHEN** user adds a mount with mount_path "/app/config.json" and content "{}"
|
||||
- **THEN** the mount is stored and linked to the profile
|
||||
|
||||
### Requirement: Cycle detection in includes
|
||||
The system SHALL prevent creation of include cycles.
|
||||
|
||||
#### Scenario: Attempt cyclic include
|
||||
- **WHEN** user tries to include profile B in profile A where A is already included in B
|
||||
- **THEN** the system rejects the request with an error
|
||||
@@ -1,22 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tool instance can have selected profile
|
||||
The system SHALL allow setting an explicit config profile on a tool instance.
|
||||
|
||||
#### Scenario: Assign profile to instance
|
||||
- **WHEN** user sets selected_profile_id on a tool instance
|
||||
- **THEN** the instance stores the profile ID and uses it at launch time
|
||||
|
||||
### Requirement: Tool instance uses default profile when none selected
|
||||
The system SHALL resolve a default profile for a tool instance when no explicit profile is selected.
|
||||
|
||||
#### Scenario: Fallback to user default
|
||||
- **WHEN** a tool instance has no selected_profile_id
|
||||
- **THEN** the system uses the user's default profile for that tool type, or the global default
|
||||
|
||||
### Requirement: Remove legacy auto-mount behavior
|
||||
The system SHALL no longer auto-mount the active config folder at tool launch time.
|
||||
|
||||
#### Scenario: Launch without active folder
|
||||
- **WHEN** a tool instance launches with no selected profile and no default
|
||||
- **THEN** the instance starts without mounting any config folder
|
||||
@@ -1,15 +0,0 @@
|
||||
## 1. Data Models and Migrations
|
||||
|
||||
- [x] 1.1 Create ConfigProfile model with user ownership, name, description
|
||||
- [x] 1.2 Create ConfigInclude model for ordered profile self-references
|
||||
- [x] 1.3 Create ConfigMount model for mount/file definitions
|
||||
- [x] 1.4 Add selected_profile_id to ToolInstance model
|
||||
- [x] 1.5 Add default profile fields to UserConfig model
|
||||
- [x] 1.6 Create Alembic migration for new tables and columns
|
||||
- [x] 1.7 Register new models in models/__init__.py
|
||||
- [x] 1.8 Add migration metadata and test
|
||||
|
||||
## 2. Legacy Deprecation
|
||||
|
||||
- [x] 2.1 Mark config_folders.is_active as deprecated in model
|
||||
- [ ] 2.2 Remove auto-mounting logic from tool launch (separate change)
|
||||
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-22
|
||||
@@ -1,106 +0,0 @@
|
||||
# UI Redesign - Design
|
||||
|
||||
## Information Architecture
|
||||
|
||||
```
|
||||
App
|
||||
├── Home
|
||||
│ ├── Hero / status
|
||||
│ ├── Open sessions
|
||||
│ ├── Available projects
|
||||
│ └── Session creation
|
||||
├── Projects
|
||||
├── Settings
|
||||
│ ├── General
|
||||
│ ├── SSH Keys
|
||||
│ ├── Tool Types
|
||||
│ └── Tool Configs
|
||||
└── Legacy routes
|
||||
└── Redirect to new locations
|
||||
```
|
||||
|
||||
## Home Page
|
||||
|
||||
### Purpose
|
||||
|
||||
Provide a fast, glanceable overview of the user's active work.
|
||||
|
||||
### Sections
|
||||
|
||||
1. **Hero**
|
||||
- Greeting
|
||||
- Short status line
|
||||
- Primary actions: New Project, Open Session, Settings
|
||||
|
||||
2. **Summary strip**
|
||||
- Small count cards for sessions, projects, and tooling state
|
||||
|
||||
3. **Open Sessions**
|
||||
- Primary section
|
||||
- Session cards with project, repository, tool type, status, and actions
|
||||
|
||||
4. **Available Projects**
|
||||
- Secondary section
|
||||
- Project cards with quick entry into the project workspace
|
||||
|
||||
5. **Session composer**
|
||||
- Optional compact create flow if it fits the page cleanly
|
||||
|
||||
## Settings Page
|
||||
|
||||
### Layout
|
||||
|
||||
Tabbed shell with one content area and four tabs:
|
||||
|
||||
- General
|
||||
- SSH Keys
|
||||
- Tool Types
|
||||
- Tool Configs
|
||||
|
||||
### Tab Responsibilities
|
||||
|
||||
**General**
|
||||
- Theme
|
||||
- Git identity
|
||||
- Default editor
|
||||
|
||||
**SSH Keys**
|
||||
- List keys
|
||||
- Create key
|
||||
- Copy public key
|
||||
- Delete key
|
||||
|
||||
**Tool Types**
|
||||
- Browse tool catalog
|
||||
- Edit custom tool types
|
||||
- Delete custom tool types
|
||||
|
||||
**Tool Configs**
|
||||
- Browse per-tool configurations
|
||||
- Add/edit/delete configs
|
||||
- Keep the existing config model and API behavior
|
||||
|
||||
## Visual Direction
|
||||
|
||||
- Font: Inter for UI text
|
||||
- Code font: monospace only for technical fields
|
||||
- Palette: warm light surfaces, forest green primary, muted utility accents
|
||||
- Dark mode: charcoal surfaces with softened accents
|
||||
- Styling: editorial, structured, high-contrast hierarchy, minimal chrome
|
||||
|
||||
## Routing
|
||||
|
||||
- `/` -> Home
|
||||
- `/sessions` -> redirect to `/`
|
||||
- `/settings` -> General tab
|
||||
- `/settings/ssh-keys` -> SSH Keys tab
|
||||
- `/settings/tool-types` -> Tool Types tab
|
||||
- `/settings/tool-configs` -> Tool Configs tab
|
||||
- legacy `/ssh-keys`, `/tool-types`, `/tool-configs` -> redirect to settings tabs
|
||||
|
||||
## Component Strategy
|
||||
|
||||
- Reuse shell and existing APIs
|
||||
- Replace the dashboard page with the new home overview
|
||||
- Convert the settings layout into a shared tab shell
|
||||
- Keep changes focused to the frontend layer
|
||||
@@ -1,35 +0,0 @@
|
||||
# UI Redesign: Home + Settings
|
||||
|
||||
## Problem
|
||||
|
||||
The current authenticated UI is functional but fragmented. Sessions, tool setup, and settings are spread across top-level pages, and the home screen does not yet provide a strong overview of open sessions and available projects.
|
||||
|
||||
## Solution
|
||||
|
||||
Redesign the authenticated frontend around two primary surfaces:
|
||||
|
||||
1. **Home**: an overview of open sessions and available projects
|
||||
2. **Settings**: a tabbed settings hub with General, SSH Keys, Tool Types, and Tool Configs
|
||||
|
||||
Keep existing functionality and the Project -> Repository -> Session hierarchy intact. Reuse the current APIs and workflows.
|
||||
|
||||
## Scope
|
||||
|
||||
- Redesign the main landing page into an operational overview
|
||||
- Fold the Sessions page into the home experience
|
||||
- Convert SSH Keys, Tool Types, and Tool Configs into settings tabs
|
||||
- Update navigation and routes to match the new IA
|
||||
- Refresh visual design, typography, and spacing
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No backend behavior changes
|
||||
- No new session or project APIs
|
||||
- No changes to the project/repository/session data model
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Home shows open sessions and available projects clearly
|
||||
- Settings contains tabs for General, SSH Keys, Tool Types, Tool Configs
|
||||
- Old top-level settings-related routes redirect to the new structure
|
||||
- Visual system uses Inter and a refined warm palette
|
||||
@@ -1,34 +0,0 @@
|
||||
# UI Redesign - Tasks
|
||||
|
||||
## 1. Visual System
|
||||
|
||||
- [ ] Update global typography to Inter
|
||||
- [ ] Refine color tokens for the new warm editorial palette
|
||||
- [ ] Add styling for new home sections and settings tabs
|
||||
|
||||
## 2. Navigation and Routing
|
||||
|
||||
- [ ] Remove Sessions from top-level navigation
|
||||
- [ ] Keep SSH Keys, Tool Types, and Tool Configs accessible from Settings tabs
|
||||
- [ ] Add redirects for legacy top-level config routes
|
||||
- [ ] Redirect `/sessions` to `/`
|
||||
|
||||
## 3. Home Page
|
||||
|
||||
- [ ] Redesign the home page as an overview of open sessions and projects
|
||||
- [ ] Add summary cards and hero actions
|
||||
- [ ] Reuse existing session and project data
|
||||
- [ ] Keep create/open session actions available
|
||||
|
||||
## 4. Settings Hub
|
||||
|
||||
- [ ] Turn Settings into a tabbed hub
|
||||
- [ ] Build General, SSH Keys, Tool Types, and Tool Configs tabs
|
||||
- [ ] Reuse existing APIs and forms
|
||||
- [ ] Keep the Project settings page separate
|
||||
|
||||
## 5. Cleanup and Verification
|
||||
|
||||
- [ ] Remove obsolete top-level pages from navigation flow
|
||||
- [ ] Update tests for the new landing page and redirects
|
||||
- [ ] Run typecheck, lint, and build
|
||||
@@ -1,213 +0,0 @@
|
||||
# OpenSpec Status and Implementation Checklist Review
|
||||
|
||||
**Review Date:** 2026-05-24
|
||||
**Reviewer:** Worker el-2i1s
|
||||
**Task:** 6.3 Final OpenSpec status and checklist review
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This review covers all active OpenSpec changes in the `openspec/changes/` directory. Out of **11 active changes** with **345 total tasks**, **66 tasks (19.1%) are complete** and **279 tasks remain**.
|
||||
|
||||
### Key Findings
|
||||
|
||||
- **2 changes are near completion** (git-repo-working-clones at 87.5%, opencode-web-terminal at 72.7%)
|
||||
- **2 changes have partial progress** (session-management-fixes at 32%, tool-workshop at 24.6%)
|
||||
- **7 changes have not started** (0% complete)
|
||||
- **1 new change was recently created** (add-config-profiles) with initial model work already implemented
|
||||
|
||||
---
|
||||
|
||||
## Active Changes Status
|
||||
|
||||
### Near Completion (>50%)
|
||||
|
||||
#### 1. git-repo-working-clones (87.5% complete)
|
||||
- **Completed:** 7/8 tasks
|
||||
- **Remaining:** Task 4.1 (Run targeted API tests)
|
||||
- **Status:** All implementation complete, only testing remains
|
||||
- **Recommendation:** Complete the remaining test task and archive
|
||||
|
||||
#### 2. opencode-web-terminal (72.7% complete)
|
||||
- **Completed:** 16/22 tasks
|
||||
- **Remaining:** Tasks 6.1-6.4 (testing and quality gates)
|
||||
- **Status:** Phases 1-5 complete (models, API, frontend, migrations)
|
||||
- **Recommendation:** Run backend tests, typecheck, and lint to complete
|
||||
|
||||
### In Progress (20-50%)
|
||||
|
||||
#### 3. session-management-fixes (32% complete)
|
||||
- **Completed:** 8/25 tasks
|
||||
- **Remaining:** All frontend work (phases 3-4, 5.3-5.6) and quality gates
|
||||
- **Status:** Backend tunnel work complete; frontend confirmation dialogs, health polling, and UI updates pending
|
||||
- **Blockers:** Frontend tasks depend on backend being deployed
|
||||
|
||||
#### 4. tool-workshop (24.6% complete)
|
||||
- **Completed:** 35/142 tasks
|
||||
- **Remaining:** 107 tasks across phases 2-5
|
||||
- **Status:** Phase 1 (Backend Foundation) nearly complete (35/37 tasks)
|
||||
- **Blockers:** Phase 2 (Instance Creation Enhancement) not started; includes docker build service, compose generation, config folder mounting, readiness probes
|
||||
|
||||
### Not Started (0%)
|
||||
|
||||
#### 5. cloudflare-tunnel-instances (0% complete)
|
||||
- **Tasks:** 28 across 6 phases
|
||||
- **Status:** No work started
|
||||
- **Dependencies:** May depend on instance-proxy being complete
|
||||
|
||||
#### 6. git-repo-ssh-clone-check (0% complete)
|
||||
- **Tasks:** 11 across 4 phases
|
||||
- **Status:** No work started
|
||||
- **Relationship:** Related to git-repo-working-clones
|
||||
|
||||
#### 7. instance-proxy (0% complete)
|
||||
- **Tasks:** 15 across 4 phases
|
||||
- **Status:** No work started
|
||||
- **Note:** May be superseded by cloudflare-tunnel-instances approach
|
||||
|
||||
#### 8. sessions-hub (0% complete)
|
||||
- **Tasks:** 18 across 6 phases
|
||||
- **Status:** No work started
|
||||
- **Dependencies:** Frontend foundation, session management APIs
|
||||
|
||||
#### 9. tool-config-management (0% complete)
|
||||
- **Tasks:** 22 across 7 phases
|
||||
- **Status:** No work started
|
||||
- **Relationship:** Related to tool-config-ui-rework and tool-workshop
|
||||
|
||||
#### 10. tool-config-ui-rework (0% complete)
|
||||
- **Tasks:** 36 across 8 phases
|
||||
- **Status:** No work started
|
||||
- **Relationship:** Related to tool-config-management
|
||||
|
||||
#### 11. ui-redesign-home-settings (0% complete)
|
||||
- **Tasks:** 18 across 5 phases
|
||||
- **Status:** No work started
|
||||
- **Dependencies:** Sessions hub, settings pages
|
||||
|
||||
### Newly Created
|
||||
|
||||
#### 12. add-config-profiles (partially implemented, not tracked)
|
||||
- **Tasks:** 10 across 2 sections
|
||||
- **Completed:** ~5/10 tasks (models created, migrations pending)
|
||||
- **Status:** Models implemented but not checked off in tasks.md
|
||||
- **Work Done:**
|
||||
- ConfigProfile model created with user ownership, name, description
|
||||
- ConfigInclude model created for ordered profile self-references
|
||||
- ConfigMount model created for mount/file definitions
|
||||
- selected_profile_id added to ToolInstance model
|
||||
- default profile fields added to UserConfig model
|
||||
- Models registered in models/__init__.py
|
||||
- **Remaining:**
|
||||
- Alembic migration
|
||||
- Migration metadata and testing
|
||||
- Legacy deprecation markings
|
||||
|
||||
---
|
||||
|
||||
## Archived Changes
|
||||
|
||||
**25 changes** have been successfully archived in `openspec/changes/archive/`, including:
|
||||
- auth-oauth, database-models, frontend-foundation
|
||||
- tool-instances, tool-terminal, git-control
|
||||
- api-documentation, workspace-visual-overhaul
|
||||
- And others
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Immediate Actions (This Sprint)
|
||||
|
||||
- [ ] **Complete git-repo-working-clones**: Run task 4.1 (targeted API tests)
|
||||
- [ ] **Complete opencode-web-terminal**: Run tasks 6.1-6.4 (tests and quality gates)
|
||||
- [ ] **Archive completed changes**: Move git-repo-working-clones and opencode-web-terminal to archive once tests pass
|
||||
|
||||
### Short-Term (Next 1-2 Sprints)
|
||||
|
||||
- [ ] **session-management-fixes frontend**: Implement confirmation dialogs, health polling, recreate tunnel button
|
||||
- [ ] **tool-workshop Phase 2**: Begin docker build service, compose generation, config folder mounting
|
||||
- [ ] **add-config-profiles**: Create Alembic migration, test models, mark legacy deprecation
|
||||
|
||||
### Medium-Term (Next 3-4 Sprints)
|
||||
|
||||
- [ ] **cloudflare-tunnel-instances**: Evaluate dependency on instance-proxy; decide approach
|
||||
- [ ] **sessions-hub**: Implement after session-management-fixes is complete
|
||||
- [ ] **ui-redesign-home-settings**: Coordinate with sessions-hub completion
|
||||
|
||||
### Backlog / Needs Prioritization
|
||||
|
||||
- [ ] **git-repo-ssh-clone-check**: Determine if still needed after git-repo-working-clones
|
||||
- [ ] **instance-proxy**: Determine if superseded by cloudflare-tunnel-instances
|
||||
- [ ] **tool-config-management**: Evaluate overlap with tool-workshop and tool-config-ui-rework
|
||||
- [ ] **tool-config-ui-rework**: Evaluate overlap with tool-config-management
|
||||
|
||||
---
|
||||
|
||||
## Quality Gates Status
|
||||
|
||||
### Backend
|
||||
|
||||
| Gate | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| ruff (linting) | Unknown | Not run in this review |
|
||||
| mypy (type checking) | Unknown | Not run in this review |
|
||||
| pytest (tests) | Unknown | Not run in this review |
|
||||
| bandit (security) | Unknown | Not run in this review |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Gate | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| TypeScript typecheck | Unknown | Not run in this review |
|
||||
| ESLint | Unknown | Not run in this review |
|
||||
| Build | Unknown | Not run in this review |
|
||||
| Vitest tests | Unknown | Not run in this review |
|
||||
|
||||
**Note:** Tasks 6.1 (Backend quality gates) and 6.2 (Frontend quality gates) are dependencies for this review but are currently blocked. A follow-up task should run these gates and report results.
|
||||
|
||||
---
|
||||
|
||||
## Risks and Blockers
|
||||
|
||||
1. **Testing Bottleneck**: Both near-complete changes are blocked on test execution
|
||||
2. **Frontend Lag**: session-management-fixes has complete backend but all frontend work pending
|
||||
3. **Massive Scope**: tool-workshop is 41% of all active tasks with most work not started
|
||||
4. **Parallel Unstarted Work**: 7 of 11 changes have 0% progress
|
||||
5. **Dependency Confusion**: instance-proxy and cloudflare-tunnel-instances may be competing approaches
|
||||
6. **Legacy Migration**: add-config-profiles introduces breaking changes to config_folders behavior
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Focus on completions**: Finish git-repo-working-clones and opencode-web-terminal first
|
||||
2. **Archive promptly**: Move completed changes to archive to reduce cognitive load
|
||||
3. **Clarify proxy approach**: Decide between instance-proxy and cloudflare-tunnel-instances
|
||||
4. **Merge overlapping changes**: Consider consolidating tool-config-management, tool-config-ui-rework, and tool-workshop
|
||||
5. **Run quality gates**: Execute tasks 6.1 and 6.2 before claiming any change is complete
|
||||
6. **Document breaking changes**: Ensure add-config-profiles migration plan is well-documented
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Task Count by Change
|
||||
|
||||
| Change | Total | Complete | Remaining | % |
|
||||
|--------|-------|----------|-----------|---|
|
||||
| cloudflare-tunnel-instances | 28 | 0 | 28 | 0.0% |
|
||||
| git-repo-ssh-clone-check | 11 | 0 | 11 | 0.0% |
|
||||
| git-repo-working-clones | 8 | 7 | 1 | 87.5% |
|
||||
| instance-proxy | 15 | 0 | 15 | 0.0% |
|
||||
| opencode-web-terminal | 22 | 16 | 6 | 72.7% |
|
||||
| session-management-fixes | 25 | 8 | 17 | 32.0% |
|
||||
| sessions-hub | 18 | 0 | 18 | 0.0% |
|
||||
| tool-config-management | 22 | 0 | 22 | 0.0% |
|
||||
| tool-config-ui-rework | 36 | 0 | 36 | 0.0% |
|
||||
| tool-workshop | 142 | 35 | 107 | 24.6% |
|
||||
| ui-redesign-home-settings | 18 | 0 | 18 | 0.0% |
|
||||
| **TOTAL** | **345** | **66** | **279** | **19.1%** |
|
||||
|
||||
---
|
||||
|
||||
*Review completed. Recommend archiving this document in the workspace documentation.*
|
||||
Reference in New Issue
Block a user