diff --git a/CHANGELOG.md b/CHANGELOG.md index fb88964..4195dbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog +## [0.37.16.0] — 2026-03-24 + +### Summary + +Projects Surface. Card-grid list + two-column detail view with inline ChatPane, +context menu (color/archive/delete), sidebar sections (description, instructions, +KBs, notes, files), star toggle, deep-linking, and search/sort. UserMenu in header +on both views. Backend was already complete (16 protected + 2 admin endpoints, +18 SDK methods); this changeset delivers the full Preact frontend. + +### Added + +- **Projects list view:** Card grid with search filter, activity/name sort, + "+ New project" via `sw.prompt` dialog, UserMenu in header left-of-title. + (`src/js/sw/surfaces/projects/list.js`, `src/css/sw-projects-surface.css`) +- **Projects detail view:** Two-column layout — left panel for conversations + (inline ChatPane, "+ New" channel creation, channel list with back nav), + right sidebar for description/instructions textareas with save, collapsible + Knowledge Bases/Notes/Files sections with "+" picker dropdowns. + (`src/js/sw/surfaces/projects/detail.js`) +- **Context menu:** "..." button opens dropdown with Rename, 8 color swatches, + Archive/Unarchive toggle, Delete with confirm dialog. All actions persist + via `PUT /api/v1/projects/:id`. (`detail.js`) +- **Star toggle:** Optimistic ☆↔★ toggle, persists in `settings.starred`. + (`detail.js`) +- **Deep-linking:** `/projects/:id` loads detail view directly via + `window.__PROJECT_ID__` template injection. Invalid IDs gracefully fall + back to list view. (`src/js/sw/surfaces/projects/index.js`, + `server/pages/loaders.go`, `server/pages/templates/surfaces/projects.html`) +- **Surface registration:** `/projects` route with `:id` param, nginx proxy, + docker-entrypoint FE route, conditional CSS/JS loading in `base.html`. + (`server/pages/pages.go`, `nginx.conf`, `docker-entrypoint-fe.sh`, + `server/pages/templates/base.html`) + +### Fixed + +- **Picker section expand:** `openPicker()` now auto-expands the collapsed + section when "+" is clicked, so the picker dropdown is visible without + manually toggling the chevron first. (`detail.js`) + +### Changed + +- **UserMenu in header (list view):** Moved from bottom footer to header row, + left of "Projects" title. Consistent with detail view placement. Removed + `.sw-projects__list-footer` CSS class. (`list.js`, `sw-projects-surface.css`) + +### Known Limitations + +- **File upload:** UI is wired (dropzone + click handler) but backend returns + "failed to save file metadata" — requires workspace storage layer. File + upload (archive upload/download) is required for MVP; deferred to v0.37.17 + (Workspaces). Git integration is optional/post-MVP. +- **Conversation names:** Channel list shows raw UUID instead of friendly name + (channels created as type `direct` have no name field). + +--- + ## [0.37.15.0] — 2026-03-23 ### Summary diff --git a/docker-entrypoint-fe.sh b/docker-entrypoint-fe.sh index b77e408..28abc41 100644 --- a/docker-entrypoint-fe.sh +++ b/docker-entrypoint-fe.sh @@ -76,6 +76,7 @@ $(page_proxy_block "= ${BASE_PATH}/login") $(page_proxy_block "${BASE_PATH}/admin") $(page_proxy_block "${BASE_PATH}/editor") $(page_proxy_block "${BASE_PATH}/notes") +$(page_proxy_block "${BASE_PATH}/projects") $(page_proxy_block "${BASE_PATH}/settings") $(page_proxy_block "${BASE_PATH}/w") # v0.27.0: Extension surface page routes diff --git a/docs/DESIGN-MULTI-FILE-STARLARK.md b/docs/DESIGN-MULTI-FILE-STARLARK.md new file mode 100644 index 0000000..f2f8d30 --- /dev/null +++ b/docs/DESIGN-MULTI-FILE-STARLARK.md @@ -0,0 +1,456 @@ +# DESIGN — Multi-File Starlark Packages + +Promotes Starlark scripts from a single inline blob to a proper +file tree with `load()` support. Prerequisite for library packages. + +--- + +## Current State + +Three problems: + +1. **Scripts live in the DB.** The installer injects `script.star` + contents into `manifest["_starlark_script"]`. The runner reads + the script from the manifest JSONB column at runtime. This means + the entire script — no matter how large — is serialized into a + JSON string inside a JSON object inside a database row. + +2. **`load()` is disabled.** The sandbox callback hard-returns an + error. There's no way to split code across files. + +3. **Only static assets are extracted.** `extractableRelPath()` + allows `js/`, `css/`, `assets/` — nothing else. Starlark files + land in the DB, not on disk. + +The hotfix in v0.37.14 (reading `script.star` from the archive and +injecting it as `_starlark_script`) made single-file clean but +didn't fix the underlying architecture. + +--- + +## Design + +### Archive Structure + +``` +gitea-client/ +├── manifest.json ← metadata only, no _starlark_script +├── script.star ← entry point (required for starlark tier) +├── star/ ← optional: additional modules +│ ├── repos.star +│ ├── issues.star +│ ├── auth.star +│ └── ci.star +├── js/ ← optional: surface assets +│ └── main.js +└── css/ + └── main.css +``` + +Convention: `script.star` is always the entry point. Submodules live +in `star/`. This parallels `js/main.js` as the JS entry point with +supporting files alongside it. + +### Manifest Change + +The manifest gains an optional `entry_point` field for packages that +want a different entry script name. Default: `script.star`. + +```json +{ + "id": "gitea-client", + "type": "library", + "tier": "starlark", + "entry_point": "script.star" +} +``` + +`_starlark_script` is no longer written to the manifest. The field +is still read for backward compatibility (existing packages that have +it in their DB row continue to work until reinstalled). + +### Extraction + +**`extractableRelPath()`** adds `star/` and bare `*.star` files: + +```go +var staticPrefixes = []string{"js/", "css/", "assets/", "star/"} + +func extractableRelPath(name string) string { + // Existing prefix matching for directories... + + // Also extract bare .star files at archive root + base := filepath.Base(name) + if strings.HasSuffix(base, ".star") { + // Strip leading directory (package-id/) if present + if idx := strings.Index(name, "/"); idx >= 0 { + rest := name[idx+1:] + if rest == base || strings.HasPrefix(rest, "star/") { + return rest + } + } + if name == base { + return name + } + } + + return "" +} +``` + +After install, the packages directory looks like: + +``` +/data/packages/gitea-client/ +├── script.star +├── star/ +│ ├── repos.star +│ ├── issues.star +│ ├── auth.star +│ └── ci.star +├── js/ +│ └── main.js +└── css/ + └── main.css +``` + +### Installer Changes + +**`packages.go` `InstallPackage()`:** + +1. Stop injecting `_starlark_script` into the manifest. Remove the + hotfix code that reads `script.star` from the archive and stuffs + it into the manifest JSON. + +2. Let `extractableRelPath()` handle `.star` files naturally — they + get extracted to disk alongside `js/` and `css/`. + +3. Validate: if `tier == "starlark"`, confirm the archive contains + `script.star` (or the manifest's `entry_point` value). Return + 400 if missing. + +### Runner Changes + +**`runner.go` `ExecPackage()`:** + +Replace manifest-based script loading with file-based: + +```go +func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration, rc *RunContext) (*Result, error) { + if pkg.Status != models.PackageStatusActive { + return nil, fmt.Errorf("package %q is %s, not active", pkg.ID, pkg.Status) + } + if pkg.Tier != models.ExtTierStarlark { + return nil, fmt.Errorf("package %q is tier %s, not starlark", pkg.ID, pkg.Tier) + } + + // ── Load script from disk (primary) or manifest (legacy) ── + script, err := r.loadScript(pkg) + if err != nil { + return nil, err + } + + modules, err := r.buildModules(ctx, pkg.ID, pkg.Manifest, rc) + if err != nil { + return nil, fmt.Errorf("failed to build modules for %q: %w", pkg.ID, err) + } + + // Build package-scoped load callback + loader := r.packageLoader(pkg.ID, modules) + + log.Printf(" 🔧 runner: exec %s (%d modules granted)", pkg.ID, len(modules)) + + return r.sandbox.ExecWithLoader(ctx, pkg.ID+"/script.star", script, modules, loader) +} + +func (r *Runner) loadScript(pkg *store.PackageRegistration) (string, error) { + // Primary: read from disk + if r.packagesDir != "" { + entryPoint := "script.star" + if ep, ok := pkg.Manifest["entry_point"].(string); ok && ep != "" { + entryPoint = ep + } + path := filepath.Join(r.packagesDir, pkg.ID, entryPoint) + data, err := os.ReadFile(path) + if err == nil && len(data) > 0 { + return string(data), nil + } + // Fall through to legacy + } + + // Legacy: inline in manifest + script, ok := pkg.Manifest["_starlark_script"].(string) + if ok && script != "" { + return script, nil + } + + return "", fmt.Errorf("package %q: no script.star on disk and no _starlark_script in manifest", pkg.ID) +} +``` + +### Sandbox Changes + +**`sandbox.go`** gains `ExecWithLoader()` — same as `Exec()` but +accepts a `load` callback: + +```go +// LoadFunc resolves load("path") calls to Starlark source. +// Returns the module's globals. The sandbox caches results per +// thread (Starlark handles this via thread.Load dedup). +type LoadFunc func(thread *starlark.Thread, module string) (starlark.StringDict, error) + +func (s *Sandbox) ExecWithLoader(ctx context.Context, filename, source string, + modules map[string]starlark.Value, loader LoadFunc) (*Result, error) { + + // ... same as Exec() but thread.Load = loader instead of error ... +} +``` + +The existing `Exec()` becomes a thin wrapper that passes a +nil/error loader for backward compatibility. + +### Package-Scoped Loader + +**`runner.go`** builds the load callback scoped to the package's +directory: + +```go +func (r *Runner) packageLoader(pkgID string, modules map[string]starlark.Value) LoadFunc { + if r.packagesDir == "" { + return nil // no disk = no load support + } + + pkgDir := filepath.Join(r.packagesDir, pkgID) + cache := make(map[string]*loadEntry) // dedup + cycle detection + + return func(thread *starlark.Thread, module string) (starlark.StringDict, error) { + // ── Security: reject traversal ── + if strings.Contains(module, "..") || filepath.IsAbs(module) { + return nil, fmt.Errorf("load: path traversal not allowed: %q", module) + } + + // ── Resolve to package directory ── + resolved := filepath.Join(pkgDir, module) + if !strings.HasPrefix(filepath.Clean(resolved), filepath.Clean(pkgDir)) { + return nil, fmt.Errorf("load: path escapes package directory: %q", module) + } + + // ── Must be a .star file ── + if !strings.HasSuffix(resolved, ".star") { + return nil, fmt.Errorf("load: only .star files can be loaded: %q", module) + } + + // ── Cache / cycle detection ── + if entry, ok := cache[module]; ok { + if entry.loading { + return nil, fmt.Errorf("load: circular dependency: %q", module) + } + return entry.globals, nil + } + + entry := &loadEntry{loading: true} + cache[module] = entry + + // ── Read and execute ── + data, err := os.ReadFile(resolved) + if err != nil { + return nil, fmt.Errorf("load: %q not found in package %q", module, pkgID) + } + + globals, err := starlark.ExecFile(thread, module, string(data), + r.predeclaredForLoad(modules)) + if err != nil { + return nil, fmt.Errorf("load: error in %q: %w", module, err) + } + + entry.globals = globals + entry.loading = false + return globals, nil + } +} + +type loadEntry struct { + loading bool + globals starlark.StringDict +} +``` + +Key constraints: + +- **Package-scoped.** Can only load files from within the package's + own directory. Path traversal (`..`) and absolute paths are rejected. + One package cannot load another package's files. + +- **`.star` files only.** Cannot load `.js`, `.json`, or anything + else. This is a Starlark sandbox, not a file reader. + +- **Circular dependency detection.** If A loads B loads A → error. + +- **Cached per invocation.** If `script.star` and `repos.star` both + load `auth.star`, it executes once. Starlark's thread-level load + dedup handles this, plus our explicit cache. + +- **Same predeclared modules.** Loaded files get the same `db`, + `http`, `json`, `settings`, `connections` modules as the entry + point. They run in the same permission context. + +### Starlark Usage + +```python +# script.star (entry point) +load("star/auth.star", "auth_headers", "auth_headers_json") +load("star/repos.star", "get_repos", "sync_repos") +load("star/issues.star", "get_issues", "create_issue", "cache_issue") +load("star/ci.star", "get_ci_status") + +def on_request(req): + # ... dispatch to imported functions ... + +def on_tool_call(tool_name, params): + # ... dispatch to imported functions ... +``` + +```python +# star/auth.star +def auth_headers(conn): + return {"Authorization": "token " + conn.get("api_token", "")} + +def auth_headers_json(conn): + h = auth_headers(conn) + h["Content-Type"] = "application/json" + return h +``` + +```python +# star/repos.star +load("star/auth.star", "auth_headers") + +def get_repos(conn): + url = conn["base_url"] + "/api/v1/repos/search?limit=50" + resp = http.get(url=url, headers=auth_headers(conn)) + if int(resp["status"]) >= 400: + return None + return json.decode(resp["body"]) + +def sync_repos(conn): + repos = get_repos(conn) + # ... db.insert into private tables ... +``` + +Submodules can load other submodules. The dependency graph is a DAG +(cycles are rejected). + +### Build Script + +**`packages/build.sh`** already includes `script.star`. Add `star/`: + +```bash +local dirs="" +[ -d "$dir/js" ] && dirs="$dirs js/" +[ -d "$dir/css" ] && dirs="$dirs css/" +[ -d "$dir/assets" ] && dirs="$dirs assets/" +[ -f "$dir/script.star" ] && dirs="$dirs script.star" +[ -d "$dir/star" ] && dirs="$dirs star/" +[ -d "$dir/migrations" ] && dirs="$dirs migrations/" +``` + +### Runner Constructor + +**`runner.go`** gains `packagesDir`: + +```go +type Runner struct { + sandbox *Sandbox + stores store.Stores + packagesDir string // NEW + notifier NotificationSender + resolver ProviderResolver + db *sql.DB + dbPostgres bool +} + +func NewRunner(sb *Sandbox, stores store.Stores) *Runner { + return &Runner{sandbox: sb, stores: stores} +} + +func (r *Runner) SetPackagesDir(dir string) { + r.packagesDir = dir +} +``` + +**`main.go`** wires it: + +```go +starlarkRunner := sandbox.NewRunner(starlarkSandbox, stores) +starlarkRunner.SetPackagesDir(cfg.StoragePath + "/packages") +``` + +--- + +## Backward Compatibility + +| Scenario | Behavior | +|----------|----------| +| Existing package with `_starlark_script` in manifest, no files on disk | Works. `loadScript()` falls through to legacy path. | +| Package installed with v0.37.14 hotfix (`script.star` injected into manifest) | Works. Same legacy path. | +| New package with `script.star` on disk, no `_starlark_script` in manifest | Works. Primary path. | +| New package with `script.star` + `star/` submodules | Works. Loader resolves `load()` calls. | +| Package with both `_starlark_script` AND `script.star` on disk | Disk wins. Manifest is legacy fallback only. | +| Package that calls `load()` but has no files on disk | Error: "load() not available (no package directory)". | + +No migration needed. Old packages work as-is. New packages benefit +from the file-based path. Reinstalling an old package extracts its +`script.star` to disk (via the updated `extractableRelPath`). + +--- + +## Security + +| Threat | Mitigation | +|--------|------------| +| Path traversal (`load("../../etc/passwd")`) | `..` rejected. `filepath.Clean` + prefix check against package dir. | +| Absolute paths (`load("/etc/shadow")`) | `filepath.IsAbs` check → rejected. | +| Loading non-Starlark files (`load("js/main.js")`) | `.star` suffix required. | +| Cross-package load (`load("../other-pkg/script.star")`) | `..` rejected. Resolution is always relative to the package's own directory. | +| Circular loads (`A→B→A`) | `loadEntry.loading` flag → error on re-entry. | +| Symlink escape | `filepath.Clean` resolves symlinks. Production: packages dir should be on a non-symlink-following mount. | +| Infinite load depth | Starlark's thread step limit applies across all loaded files. A package that loads 100 files still runs under the same 1M step budget. | + +--- + +## Changeset Plan + +| CS | Scope | Files | +|----|-------|-------| +| CS1 | Extraction | `packages.go`: `extractableRelPath()` adds `star/`, `*.star`. Remove `_starlark_script` injection from installer. Add entry point validation. | +| CS2 | Sandbox | `sandbox.go`: `ExecWithLoader()`. `LoadFunc` type. Existing `Exec()` wraps with nil loader. | +| CS3 | Runner | `runner.go`: `packagesDir` field + setter. `loadScript()` disk-first. `packageLoader()` with security checks. `ExecPackage()` uses `ExecWithLoader()`. | +| CS4 | Wiring | `main.go`: `SetPackagesDir()`. `build.sh`: add `star/` to archive. | +| CS5 | Test | Integration test: install package with `script.star` + `star/` submodules, call `on_request`, verify `load()` resolved correctly. Install package with only `_starlark_script` in manifest, verify legacy path. | + +CS1-CS4 can land in a single session. CS5 validates. All five are +backend-only — no FE changes. + +--- + +## What This Enables + +- **Library packages** — a `gitea-client` with `script.star` as + entry point, `star/repos.star`, `star/issues.star`, `star/ci.star` + as submodules. Clean separation of concerns. + +- **Large extensions** — a workflow automation package with 20+ + tool handlers doesn't have to be one 2000-line file. + +- **Shared utilities** — within a single package, common helpers + (auth, validation, formatting) live in their own file and are + loaded by multiple entry points. + +- **Testable modules** — each `.star` file can be loaded and + tested independently in a dev sandbox. + +This does NOT enable cross-package loading. Package A cannot load +Package B's files. That's what `lib.load()` is for (see +DESIGN-EXT-CONNECTIONS-LIBRARIES.md). File-level `load()` is +intra-package. `lib.load()` is inter-package. Different mechanisms, +different trust boundaries. \ No newline at end of file diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 33a740e..1f54e2f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -183,10 +183,11 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface. | v0.37.12 | Scorched Earth II ✅ | 23 files deleted (~2,600 lines): 9 JS, 6 CSS, 8 admin templates; sw.js + base.html cleaned | | v0.37.13 | Scorched Earth III ✅ | 5 JS deleted (−2,269 lines): ui-primitives, code-editor, file-tree, user-menu, app-state; SDK slimmed, Theme→Preact | | v0.37.14 | SE IV + Pane audit ✅ | 4 JS deleted (~−1,672 lines); double-unwrap cleanup (93 sites); API envelope normalization (18 Go handlers); thinking tags persistence; deleteMessage endpoint; extension surface user menu fix; 7 bug fixes | -| v0.37.15 | Workflow surfaces | Form renderer, review views (design-first) | -| v0.37.16 | Projects surface | Project views, note/channel associations (design-first) | -| v0.37.17 | Debug / model surface | Debug tooling, model configuration UI; debug modal Preact rebuild (CR P2-5) | -| v0.37.18 | Tag | Light mode CSS audit, dead code hunt, all features verified; `sw.can()` RBAC gates across surfaces (CR P2-1), `__USER__`/`__PAGE_DATA__` removal (CR P2-4), `_getScale` → `sw.shell.getScale()` (CR P3-3) | +| v0.37.15 | Workflow surfaces ✅ | Workflow ownership/lifecycle, stage CRUD, team-admin tabs, assignments queue | +| v0.37.16 | Projects surface ✅ | Card grid + detail, inline ChatPane, context menu, sidebar pickers, deep-link | +| v0.37.17 | Workspaces | File storage layer, archive upload/download (zip/tar), project file integration | +| v0.37.18 | Debug / model surface | Debug tooling, model configuration UI; debug modal Preact rebuild (CR P2-5) | +| v0.37.19 | Tag | Light mode CSS audit, dead code hunt, all features verified; `sw.can()` RBAC gates across surfaces (CR P2-1), `__USER__`/`__PAGE_DATA__` removal (CR P2-4), `_getScale` → `sw.shell.getScale()` (CR P3-3) | **v0.37.14 — Scorched Earth IV + Pane Audit ✅** (10 sessions, v0.37.14.0–.22): @@ -203,15 +204,32 @@ Team-admin workflow management as first-class path. See Instance cancel/unclaim/reassign, stage CRUD FE wiring, team-admin workflow queue + stage editor + instance monitor surfaces. -**v0.37.16 — Projects Surface:** +**v0.37.16 — Projects Surface ✅:** -Project views, note/channel associations, project-scoped navigation. +Card-grid list + two-column detail (inline ChatPane, context menu, sidebar +pickers, star toggle, deep-linking). UserMenu in header. Backend was already +complete (16+2 endpoints, 18 SDK methods). File upload UI wired but backend +storage deferred to v0.37.17. -**v0.37.17 — Debug + Model Surface:** +**v0.37.17 — Workspaces (File Storage):** + +Workspace storage layer for project files. Archive (zip, tar, etc.) +upload/download is **required for MVP** — projects need document attachment. +Schema and model FK already exist (`workspaces` table, `Project.WorkspaceID`). +Git integration (clone, pull, branch tracking) is optional/post-MVP. + +- [ ] Workspace CRUD endpoints wiring (create-on-demand for projects) +- [ ] File upload backend: disk storage with workspace root path +- [ ] Archive upload: zip/tar extraction + individual file indexing +- [ ] File download: single file + archive download (zip bundle) +- [ ] File browser UI in project sidebar (list, preview, delete) +- [ ] Storage quota enforcement (`max_bytes` on workspace) + +**v0.37.18 — Debug + Model Surface:** Debug modal Preact rebuild, model configuration UI, provider diagnostics. -**v0.37.18 — Tag ("UI Complete"):** +**v0.37.19 — Tag ("UI Complete"):** `sw.can()` RBAC gates, `__USER__`/`__PAGE_DATA__` removal, `_getScale` SDK, light mode CSS audit, dead code hunt. Every capability has a UI. @@ -258,7 +276,7 @@ reading Go source code. Team admins build workflows visually. package management, backup/restore - [ ] Mobile-responsive layouts (proper mobile navigation, touch-optimized chat, not just CSS responsive) -- [ ] Project creation dialog (replace `prompt()` with modal) +- [x] Project creation dialog (v0.37.16, sw.prompt-based) - [ ] Admin-level project management (cross-instance visibility) --- @@ -297,10 +315,10 @@ No ordering constraints between items. - Memory analytics dashboard **Projects** -- `/p/:id` shared project view -- Project-specific file uploads (own endpoint, storage path) +- `/p/:id` shared project view (public/team-scoped read-only link) - Project templates (predefined configurations) - Sub-projects / nested hierarchy +- Git integration (clone, pull, branch tracking — optional workspace feature) **UX** - "Group" scope badge on model selector / KB list diff --git a/nginx.conf b/nginx.conf index d6f678f..639e58a 100644 --- a/nginx.conf +++ b/nginx.conf @@ -80,6 +80,7 @@ server { location /team-admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /notes { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + location /projects { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # v0.27.0: Extension surface page routes → backend (Go templates) diff --git a/packages/git-board/css/main.css b/packages/git-board/css/main.css new file mode 100644 index 0000000..830137e --- /dev/null +++ b/packages/git-board/css/main.css @@ -0,0 +1,241 @@ +/* Git Board — Surface Styles */ + +.gb-shell { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +/* ── Header ──────────────────────────────── */ + +.gb-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.gb-header__left, +.gb-header__right { + display: flex; + align-items: center; + gap: 10px; +} + +.gb-title { + font-size: 18px; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.gb-repo-picker { + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: var(--mono); + font-size: 13px; + padding: 5px 8px; + max-width: 260px; +} + +/* ── Token Setup ─────────────────────────── */ + +.gb-setup { + max-width: 480px; + margin: 60px auto; + text-align: center; + padding: 0 16px; +} + +.gb-setup h2 { + color: var(--text); + font-size: 20px; + margin: 0 0 8px; +} + +.gb-setup p { + color: var(--text-2); + font-size: 14px; + line-height: 1.5; + margin: 0 0 20px; +} + +.gb-setup__form { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.gb-setup__input { + flex: 1; + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: var(--mono); + font-size: 13px; + padding: 8px 10px; + outline: none; +} + +.gb-setup__input:focus { + border-color: var(--accent); +} + +.gb-setup__hint { + font-size: 12px; + color: var(--text-3); +} + +/* ── Kanban Board ────────────────────────── */ + +.gb-board { + display: flex; + gap: 12px; + padding: 12px 16px; + flex: 1; + overflow-x: auto; + overflow-y: hidden; +} + +.gb-column { + min-width: 260px; + max-width: 320px; + flex: 1; + display: flex; + flex-direction: column; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.gb-column__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} + +.gb-column__title { + font-size: 13px; + font-weight: 600; + color: var(--text); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.gb-column__count { + background: var(--bg-raised); + color: var(--text-2); + font-size: 11px; + font-weight: 600; + padding: 2px 7px; + border-radius: 10px; +} + +.gb-column__cards { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +/* ── Cards ───────────────────────────────── */ + +.gb-card { + display: block; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px; + text-decoration: none; + color: var(--text); + transition: border-color var(--transition), background var(--transition); + cursor: pointer; +} + +.gb-card:hover { + border-color: var(--accent); + background: var(--bg-hover); +} + +.gb-card--pr { + border-left: 3px solid var(--accent); +} + +.gb-card__header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.gb-card__number { + font-family: var(--mono); + font-size: 12px; + color: var(--text-3); +} + +.gb-card__assignee { + font-size: 11px; + color: var(--accent); + margin-left: auto; +} + +.gb-card__branch { + font-family: var(--mono); + font-size: 11px; + color: var(--text-3); + margin-left: auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 150px; +} + +.gb-card__title { + font-size: 13px; + font-weight: 500; + line-height: 1.4; + color: var(--text); +} + +.gb-card__labels { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; +} + +.gb-card__labels .badge { + font-size: 10px; + padding: 1px 6px; +} + +.gb-card__meta { + display: flex; + justify-content: space-between; + margin-top: 6px; + font-size: 11px; + color: var(--text-3); +} + +/* ── Empty state ─────────────────────────── */ + +.gb-empty { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: var(--text-3); + font-size: 14px; +} diff --git a/packages/git-board/js/main.js b/packages/git-board/js/main.js new file mode 100644 index 0000000..572a8ea --- /dev/null +++ b/packages/git-board/js/main.js @@ -0,0 +1,287 @@ +/** + * Git Board — Surface Entry Point + * + * Kanban board for Gitea/GitHub issues and PRs. + * Calls Starlark backend via /s/git-board/api/*. + * Per-user API token via extension settings. + */ +(async function () { + 'use strict'; + + var mount = document.getElementById('extension-mount'); + if (!mount) return; + + var base = window.__BASE__ || ''; + var ver = window.__VERSION__ || '0'; + var slug = 'git-board'; + + // ── Boot SDK ─────────────────────────────── + try { + if (!window.preact) { + var { h, render } = await import(base + '/js/sw/vendor/preact.module.js'); + var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js'); + var htmModule = await import(base + '/js/sw/vendor/htm.module.js'); + window.preact = { h, render }; + window.hooks = hooksModule; + window.html = htmModule.default.bind(h); + } + var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver); + await sdk.boot(); + } catch (e) { + mount.innerHTML = '

SDK boot failed: ' + e.message + '

'; + return; + } + + var { html } = window; + var { useState, useEffect, useCallback, useRef } = hooks; + var { render } = preact; + + var API = base + '/s/' + slug + '/api'; + + async function api(path) { + var token = sw.auth._getToken(); + var resp = await fetch(API + path, { + headers: { 'Authorization': 'Bearer ' + token } + }); + var text = await resp.text(); + try { return JSON.parse(text); } catch (_) { return { error: text }; } + } + + // ── App ──────────────────────────────────── + + function App() { + var [owner, setOwner] = useState(''); + var [repo, setRepo] = useState(''); + var [repos, setRepos] = useState([]); + var [board, setBoard] = useState(null); + var [loading, setLoading] = useState(false); + var [needsToken, setNeedsToken] = useState(false); + var menuRef = useRef(null); + + // Mount user menu + useEffect(function () { + if (menuRef.current && sw.userMenu) { + sw.userMenu(menuRef.current, { placement: 'down-right' }); + } + }, []); + + // Load repos on mount + useEffect(function () { + api('/repos').then(function (d) { + if (d.error && d.error.indexOf('failed') !== -1) { + setNeedsToken(true); + return; + } + setRepos(d.data || []); + if (d.data && d.data.length > 0 && !owner) { + setOwner(d.data[0].owner); + setRepo(d.data[0].name); + } + }); + }, []); + + var loadBoard = useCallback(function () { + if (!owner || !repo) return; + setLoading(true); + api('/board?owner=' + encodeURIComponent(owner) + '&repo=' + encodeURIComponent(repo)) + .then(function (d) { + if (d.error) { + sw.toast(d.error, 'error'); + if (d.error.indexOf('failed') !== -1) setNeedsToken(true); + } else { + setBoard(d); + setNeedsToken(false); + } + setLoading(false); + }); + }, [owner, repo]); + + useEffect(function () { loadBoard(); }, [loadBoard]); + + if (needsToken) return html` + <${TokenSetup} menuRef=${menuRef} /> + `; + + return html` +
+
+
+

Git Board

+ <${RepoPicker} repos=${repos} owner=${owner} repo=${repo} + onSelect=${function (o, r) { setOwner(o); setRepo(r); }} /> +
+
+ +
+
+
+ + ${board ? html`<${Board} data=${board} />` : html` +
${loading ? 'Loading…' : 'Select a repository'}
+ `} +
+ `; + } + + // ── Token Setup ──────────────────────────── + + function TokenSetup({ menuRef }) { + var [token, setToken] = useState(''); + var [saving, setSaving] = useState(false); + + async function save() { + if (!token.trim()) return; + setSaving(true); + try { + await sw.api.extensions.updateUserSettings('git-board', { api_token: token.trim() }); + sw.toast('Token saved — reloading', 'success'); + setTimeout(function () { location.reload(); }, 500); + } catch (e) { + sw.toast(e.message || 'Failed to save', 'error'); + setSaving(false); + } + } + + return html` +
+
+

Git Board

+
+
+
+

Connect Your Account

+

Enter your personal access token for Gitea or GitHub.
+ The admin configures the platform URL. You provide your own token.

+
+ + +
+

Token is stored in your personal extension settings — not shared with other users.

+
+
+ `; + } + + // ── Repo Picker ──────────────────────────── + + function RepoPicker({ repos, owner, repo, onSelect }) { + var current = owner + '/' + repo; + return html` + + `; + } + + // ── Kanban Board ─────────────────────────── + + var COLUMNS = [ + { key: 'open', title: 'Open', filter: function (i) { return i.state === 'open'; } }, + { key: 'in_progress', title: 'In Progress', filter: function (i) { return i.assignee && i.state === 'open'; } }, + { key: 'prs', title: 'Pull Requests', isPR: true }, + { key: 'closed', title: 'Done', filter: function (i) { return i.state === 'closed'; } }, + ]; + + function Board({ data }) { + var issues = data.issues || []; + var prs = data.pull_requests || []; + + // Split issues: unassigned open vs assigned open + var openUnassigned = issues.filter(function (i) { return i.state === 'open' && !i.assignee; }); + var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; }); + + return html` +
+ <${Column} title="Open" count=${openUnassigned.length}> + ${openUnassigned.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })} + + <${Column} title="In Progress" count=${inProgress.length}> + ${inProgress.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })} + + <${Column} title="Pull Requests" count=${prs.length}> + ${prs.map(function (p) { return html`<${PRCard} key=${p.number} pr=${p} />`; })} + +
+ `; + } + + function Column({ title, count, children }) { + return html` +
+
+ ${title} + ${count || 0} +
+
${children}
+
+ `; + } + + function IssueCard({ issue }) { + return html` + +
+ #${issue.number} + ${issue.assignee && html`@${esc(issue.assignee)}`} +
+
${esc(issue.title)}
+
+ ${(issue.labels || []).map(function (l) { + return html`${esc(l)}`; + })} +
+
+ `; + } + + function PRCard({ pr }) { + return html` + +
+ #${pr.number} + ${esc(pr.head)} → ${esc(pr.base)} +
+
${esc(pr.title)}
+
+ @${esc(pr.user)} + ${timeAgo(pr.created_at)} +
+
+ `; + } + + // ── Utilities ────────────────────────────── + + function esc(s) { + var el = document.createElement('span'); + el.textContent = String(s || ''); + return el.innerHTML; + } + + function timeAgo(iso) { + if (!iso) return ''; + var sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (sec < 60) return 'now'; + var min = Math.floor(sec / 60); + if (min < 60) return min + 'm'; + var hr = Math.floor(min / 60); + if (hr < 24) return hr + 'h'; + return Math.floor(hr / 24) + 'd'; + } + + // ── Mount ────────────────────────────────── + render(html`<${App} />`, mount); + console.log('[git-board] Surface mounted'); +})(); diff --git a/packages/git-board/manifest.json b/packages/git-board/manifest.json new file mode 100644 index 0000000..d440a11 --- /dev/null +++ b/packages/git-board/manifest.json @@ -0,0 +1,125 @@ +{ + "id": "git-board", + "title": "Git Board", + "type": "full", + "tier": "starlark", + "route": "/s/git-board", + "auth": "authenticated", + "layout": "single", + "version": "0.1.0", + "description": "Gitea/GitHub issue and PR board with LLM tools for reading, editing, and commenting. Per-user API key configuration.", + "author": "switchboard", + + "permissions": ["api.http"], + + "api_routes": [ + {"method": "GET", "path": "/repos"}, + {"method": "GET", "path": "/board"}, + {"method": "GET", "path": "/ci/*"} + ], + + "tools": [ + { + "name": "list_issues", + "description": "List issues for a repository. Returns titles, numbers, labels, assignees, and state.", + "parameters": { + "owner": {"type": "string", "required": true, "description": "Repository owner or org"}, + "repo": {"type": "string", "required": true, "description": "Repository name"}, + "state": {"type": "string", "required": false, "description": "Filter: open (default), closed, all"} + } + }, + { + "name": "get_issue", + "description": "Get full details for a single issue including body and comments.", + "parameters": { + "owner": {"type": "string", "required": true}, + "repo": {"type": "string", "required": true}, + "number": {"type": "number", "required": true, "description": "Issue number"} + } + }, + { + "name": "create_issue", + "description": "Create a new issue in a repository.", + "parameters": { + "owner": {"type": "string", "required": true}, + "repo": {"type": "string", "required": true}, + "title": {"type": "string", "required": true}, + "body": {"type": "string", "required": false}, + "labels": {"type": "string", "required": false, "description": "Comma-separated label names"} + } + }, + { + "name": "update_issue", + "description": "Update an existing issue (title, body, state, labels).", + "parameters": { + "owner": {"type": "string", "required": true}, + "repo": {"type": "string", "required": true}, + "number": {"type": "number", "required": true}, + "title": {"type": "string", "required": false}, + "body": {"type": "string", "required": false}, + "state": {"type": "string", "required": false, "description": "open or closed"} + } + }, + { + "name": "add_comment", + "description": "Add a comment to an issue or pull request.", + "parameters": { + "owner": {"type": "string", "required": true}, + "repo": {"type": "string", "required": true}, + "number": {"type": "number", "required": true}, + "body": {"type": "string", "required": true} + } + }, + { + "name": "list_pull_requests", + "description": "List pull requests for a repository with status, branch, and review state.", + "parameters": { + "owner": {"type": "string", "required": true}, + "repo": {"type": "string", "required": true}, + "state": {"type": "string", "required": false, "description": "open (default), closed, all"} + } + }, + { + "name": "get_ci_status", + "description": "Get CI/CD job status for a commit or branch reference.", + "parameters": { + "owner": {"type": "string", "required": true}, + "repo": {"type": "string", "required": true}, + "ref": {"type": "string", "required": true, "description": "Branch name, tag, or commit SHA"} + } + } + ], + + "settings": { + "platform": { + "type": "string", + "label": "Platform", + "description": "Git platform type: gitea or github", + "default": "gitea" + }, + "base_url": { + "type": "string", + "label": "Base URL", + "description": "Git platform API base URL (e.g. https://git.gobha.me)", + "required": true + }, + "api_token": { + "type": "string", + "label": "API Token (personal)", + "description": "Your personal access token. Set per-user in Settings > Extensions.", + "default": "" + }, + "default_owner": { + "type": "string", + "label": "Default Owner/Org", + "description": "Default repository owner for the board view.", + "default": "" + }, + "default_repo": { + "type": "string", + "label": "Default Repository", + "description": "Default repository name for the board view.", + "default": "" + } + } +} diff --git a/packages/git-board/script.star b/packages/git-board/script.star new file mode 100644 index 0000000..1a2d403 --- /dev/null +++ b/packages/git-board/script.star @@ -0,0 +1,328 @@ +# Git Board — Starlark Backend +# +# Entry points: +# on_request(req) → surface API routes +# on_tool_call(tool_name, params) → LLM tool execution +# +# Modules: +# http — outbound HTTP to git platform (requires api.http permission) +# settings — admin: platform, base_url / user: api_token, default_owner, default_repo +# json — json.encode() / json.decode() (universal, no permission needed) + +# ═══════════════════════════════════════════════ +# Surface API routes (/s/git-board/api/*) +# ═══════════════════════════════════════════════ + +def on_request(req): + path = req["path"] + method = req["method"] + + if method == "GET" and path == "/repos": + return _handle_repos(req) + elif method == "GET" and path == "/board": + return _handle_board(req) + elif method == "GET" and path.startswith("/ci/"): + return _handle_ci(req, path[len("/ci/"):]) + + return _resp(404, {"error": "not found"}) + + +def _handle_repos(req): + """List repositories accessible to the user.""" + platform = _platform() + data = _git_get(_repos_path(platform)) + if data == None: + return _resp(502, {"error": "git platform request failed"}) + + # Normalize — Gitea returns list, GitHub returns {items: [...]} + repos = data if type(data) == "list" else data.get("items", data.get("repositories", [])) + out = [] + for r in repos: + out.append({ + "full_name": r.get("full_name", ""), + "name": r.get("name", ""), + "owner": r.get("owner", {}).get("login", ""), + "description": r.get("description", ""), + "html_url": r.get("html_url", ""), + "open_issues": r.get("open_issues_count", 0), + }) + return _resp(200, {"data": out}) + + +def _handle_board(req): + """Combined issues + PRs for kanban board.""" + q = req.get("query", {}) + owner = _str(q.get("owner", "")) or settings.get("default_owner") or "" + repo = _str(q.get("repo", "")) or settings.get("default_repo") or "" + if not owner or not repo: + return _resp(400, {"error": "owner and repo required (query params or user settings)"}) + + platform = _platform() + + issues = _git_get(_issues_path(platform, owner, repo, "open")) or [] + prs = _git_get(_prs_path(platform, owner, repo, "open")) or [] + + board = {"issues": [], "pull_requests": []} + for i in issues: + labels = [l.get("name", "") for l in (i.get("labels") or [])] + board["issues"].append({ + "number": i.get("number", 0), + "title": i.get("title", ""), + "state": i.get("state", ""), + "labels": labels, + "assignee": (i.get("assignee") or {}).get("login", ""), + "created_at": i.get("created_at", ""), + "updated_at": i.get("updated_at", ""), + "html_url": i.get("html_url", ""), + }) + for p in prs: + board["pull_requests"].append({ + "number": p.get("number", 0), + "title": p.get("title", ""), + "state": p.get("state", ""), + "head": p.get("head", {}).get("ref", ""), + "base": p.get("base", {}).get("ref", ""), + "mergeable": p.get("mergeable", None), + "user": (p.get("user") or {}).get("login", ""), + "created_at": p.get("created_at", ""), + "html_url": p.get("html_url", ""), + }) + + return _resp(200, board) + + +def _handle_ci(req, ref_path): + """CI status for owner/repo/ref.""" + parts = ref_path.split("/", 2) + if len(parts) < 3: + return _resp(400, {"error": "path must be /ci/:owner/:repo/:ref"}) + owner, repo, ref = parts[0], parts[1], parts[2] + platform = _platform() + data = _git_get(_ci_path(platform, owner, repo, ref)) + if data == None: + return _resp(502, {"error": "CI status request failed"}) + return _resp(200, data) + + +# ═══════════════════════════════════════════════ +# LLM Tools (called by AI during chat) +# ═══════════════════════════════════════════════ + +def on_tool_call(tool_name, params): + platform = _platform() + owner = params.get("owner", "") + repo = params.get("repo", "") + + if tool_name == "list_issues": + state = params.get("state", "open") + data = _git_get(_issues_path(platform, owner, repo, state)) + if data == None: + return {"error": "failed to list issues"} + items = [] + for i in data: + labels = [l.get("name", "") for l in (i.get("labels") or [])] + items.append({ + "number": i.get("number", 0), + "title": i.get("title", ""), + "state": i.get("state", ""), + "labels": labels, + "assignee": (i.get("assignee") or {}).get("login", ""), + }) + return {"issues": items, "count": len(items)} + + elif tool_name == "get_issue": + num = int(params.get("number", 0)) + data = _git_get(_issue_path(platform, owner, repo, num)) + if data == None: + return {"error": "issue not found"} + # Also fetch comments + comments_data = _git_get(_issue_path(platform, owner, repo, num) + "/comments") or [] + comments = [] + for c in comments_data: + comments.append({ + "user": (c.get("user") or {}).get("login", ""), + "body": c.get("body", ""), + "created_at": c.get("created_at", ""), + }) + return { + "number": data.get("number", 0), + "title": data.get("title", ""), + "body": data.get("body", ""), + "state": data.get("state", ""), + "labels": [l.get("name", "") for l in (data.get("labels") or [])], + "assignee": (data.get("assignee") or {}).get("login", ""), + "created_at": data.get("created_at", ""), + "comments": comments, + } + + elif tool_name == "create_issue": + body = {"title": params.get("title", "")} + if params.get("body", ""): + body["body"] = params["body"] + if params.get("labels", ""): + body["labels"] = [l.strip() for l in params["labels"].split(",") if l.strip()] + data = _git_post(_issues_path(platform, owner, repo, ""), body) + if data == None: + return {"error": "failed to create issue"} + return {"number": data.get("number", 0), "title": data.get("title", ""), "html_url": data.get("html_url", "")} + + elif tool_name == "update_issue": + num = int(params.get("number", 0)) + patch = {} + if params.get("title", ""): + patch["title"] = params["title"] + if params.get("body", ""): + patch["body"] = params["body"] + if params.get("state", ""): + patch["state"] = params["state"] + data = _git_patch(_issue_path(platform, owner, repo, num), patch) + if data == None: + return {"error": "failed to update issue"} + return {"number": data.get("number", 0), "state": data.get("state", ""), "title": data.get("title", "")} + + elif tool_name == "add_comment": + num = int(params.get("number", 0)) + data = _git_post(_issue_path(platform, owner, repo, num) + "/comments", {"body": params.get("body", "")}) + if data == None: + return {"error": "failed to add comment"} + return {"id": data.get("id", 0), "body": data.get("body", "")} + + elif tool_name == "list_pull_requests": + state = params.get("state", "open") + data = _git_get(_prs_path(platform, owner, repo, state)) + if data == None: + return {"error": "failed to list PRs"} + items = [] + for p in data: + items.append({ + "number": p.get("number", 0), + "title": p.get("title", ""), + "state": p.get("state", ""), + "head": p.get("head", {}).get("ref", ""), + "base": p.get("base", {}).get("ref", ""), + "user": (p.get("user") or {}).get("login", ""), + }) + return {"pull_requests": items, "count": len(items)} + + elif tool_name == "get_ci_status": + ref = params.get("ref", "") + data = _git_get(_ci_path(platform, owner, repo, ref)) + if data == None: + return {"error": "failed to get CI status"} + # Normalize: Gitea returns {statuses: [...]}, GitHub returns {state, statuses: [...]} + statuses = data.get("statuses", []) + items = [] + for s in statuses: + items.append({ + "context": s.get("context", s.get("name", "")), + "state": s.get("state", s.get("status", "")), + "description": s.get("description", ""), + "target_url": s.get("target_url", ""), + }) + return {"ref": ref, "state": data.get("state", ""), "statuses": items, "count": len(items)} + + return {"error": "unknown tool: " + tool_name} + + +# ═══════════════════════════════════════════════ +# Git platform abstraction +# ═══════════════════════════════════════════════ + +def _platform(): + return settings.get("platform") or "gitea" + +def _base_url(): + url = settings.get("base_url") or "" + if url.endswith("/"): + url = url[:-1] + return url + +def _token(): + return settings.get("api_token") or "" + +def _api_prefix(platform): + # Gitea: /api/v1, GitHub: "" (api.github.com already has /repos etc) + if platform == "github": + return "" + return "/api/v1" + +def _repos_path(platform): + if platform == "github": + return "/user/repos?per_page=50&sort=updated" + return "/repos/search?limit=50&sort=updated" + +def _issues_path(platform, owner, repo, state): + if platform == "github": + return "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&per_page=50" + return "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&limit=50&type=issues" + +def _issue_path(platform, owner, repo, number): + return "/repos/" + owner + "/" + repo + "/issues/" + str(number) + +def _prs_path(platform, owner, repo, state): + if platform == "github": + return "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&per_page=50" + return "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&limit=50" + +def _ci_path(platform, owner, repo, ref): + if platform == "github": + return "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status" + return "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status" + + +# ═══════════════════════════════════════════════ +# HTTP helpers +# ═══════════════════════════════════════════════ + +def _auth_headers(): + token = _token() + if not token: + return {} + platform = _platform() + if platform == "github": + return {"Authorization": "Bearer " + token, "Accept": "application/vnd.github.v3+json"} + return {"Authorization": "token " + token} + +def _git_get(path): + url = _base_url() + _api_prefix(_platform()) + path + resp = http.get(url=url, headers=_auth_headers()) + if int(resp["status"]) >= 400: + return None + body = resp.get("body", "") + if not body: + return None + return json.decode(body) + +def _git_post(path, data): + url = _base_url() + _api_prefix(_platform()) + path + resp = http.post(url=url, body=json.encode(data), headers=dict(_auth_headers(), **{"Content-Type": "application/json"})) + if int(resp["status"]) >= 400: + return None + body = resp.get("body", "") + if not body: + return None + return json.decode(body) + +def _git_patch(path, data): + url = _base_url() + _api_prefix(_platform()) + path + resp = http.post(url=url, body=json.encode(data), headers=dict(_auth_headers(), **{"Content-Type": "application/json", "X-HTTP-Method-Override": "PATCH"})) + if int(resp["status"]) >= 400: + return None + body = resp.get("body", "") + if not body: + return None + return json.decode(body) + + +# ═══════════════════════════════════════════════ +# Response helpers +# ═══════════════════════════════════════════════ + +def _resp(status, data): + return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}} + +def _str(v): + """Coerce Starlark query param to string.""" + if v == None: + return "" + return str(v) diff --git a/server/pages/loaders.go b/server/pages/loaders.go index 3a02ee7..6510266 100644 --- a/server/pages/loaders.go +++ b/server/pages/loaders.go @@ -124,12 +124,18 @@ type TeamAdminPageData struct { Section string `json:"section"` } +// ProjectsPageData is what the projects surface receives. +type ProjectsPageData struct { + ProjectID string `json:"project_id"` +} + func (e *Engine) registerLoaders() { e.RegisterLoader("admin", e.adminLoader) e.RegisterLoader("chat", e.chatLoader) e.RegisterLoader("notes", e.notesLoader) e.RegisterLoader("settings", e.settingsLoader) e.RegisterLoader("team-admin", e.teamAdminLoader) + e.RegisterLoader("projects", e.projectsLoader) } // ListDataProviders returns the keys of all registered data providers. @@ -431,3 +437,8 @@ func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) { return data, nil } + +func (e *Engine) projectsLoader(c *gin.Context, s store.Stores) (any, error) { + projectID := c.Param("id") + return &ProjectsPageData{ProjectID: projectID}, nil +} diff --git a/server/pages/pages.go b/server/pages/pages.go index debf789..c5f4170 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -237,6 +237,12 @@ func (e *Engine) registerCoreSurfaces() { DataRequires: []string{"team-admin"}, Layout: "single", Source: "core", }, + { + ID: "projects", Route: "/projects/:id", AltRoutes: []string{"/projects"}, + Title: "Projects", Template: "surface-projects", Auth: "authenticated", + DataRequires: []string{"projects"}, + Layout: "single", Source: "core", + }, { ID: "workflow", Route: "/w/:id", Title: "Workflow", Template: "workflow", Auth: "session", diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html index c8d0e22..3b46b4b 100644 --- a/server/pages/templates/base.html +++ b/server/pages/templates/base.html @@ -27,6 +27,7 @@ {{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}} {{if eq .Surface "notes"}}{{template "css-notes" .}}{{end}} + {{if eq .Surface "projects"}}{{template "css-projects" .}}{{end}} {{/* v0.27.0: Extension surface CSS — loaded from /surfaces/{id}/css/main.css */}} {{if and .Manifest (eq .Manifest.Source "extension")}} @@ -96,6 +97,7 @@ {{else if eq .Surface "team-admin"}}{{template "surface-team-admin" .}} {{else if eq .Surface "notes"}}{{template "surface-notes" .}} {{else if eq .Surface "settings"}}{{template "surface-settings" .}} + {{else if eq .Surface "projects"}}{{template "surface-projects" .}} {{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}} {{else}}
Unknown surface: {{.Surface}}
{{end}} @@ -132,6 +134,7 @@ {{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}} {{if eq .Surface "notes"}}{{template "scripts-notes" .}}{{end}} {{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}} + {{if eq .Surface "projects"}}{{template "scripts-projects" .}}{{end}} {{/* v0.27.0: Extension surface JS — loaded from /surfaces/{id}/js/main.js */}} {{if and .Manifest (eq .Manifest.Source "extension")}} diff --git a/server/pages/templates/surfaces/projects.html b/server/pages/templates/surfaces/projects.html new file mode 100644 index 0000000..200eca7 --- /dev/null +++ b/server/pages/templates/surfaces/projects.html @@ -0,0 +1,32 @@ +{{/* + Projects surface — v0.37.16: Card grid + detail with inline chat. +*/}} + +{{define "surface-projects"}} +
+{{end}} + +{{define "css-projects"}} + +{{end}} + +{{define "scripts-projects"}} + + + +{{end}} diff --git a/server/sandbox/sandbox.go b/server/sandbox/sandbox.go index 65e302a..eccda45 100644 --- a/server/sandbox/sandbox.go +++ b/server/sandbox/sandbox.go @@ -24,6 +24,7 @@ import ( "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" + starlarkjson "go.starlark.net/lib/json" ) // ─── Configuration ─────────────────────────── @@ -93,10 +94,12 @@ func (s *Sandbox) Exec(ctx context.Context, filename, source string, modules map var output strings.Builder var outputMu sync.Mutex - predeclared := make(starlark.StringDict, len(modules)) + predeclared := make(starlark.StringDict, len(modules)+1) for k, v := range modules { predeclared[k] = v } + // json.encode / json.decode — universal, no permissions required (pure computation) + predeclared["json"] = starlarkjson.Module thread := &starlark.Thread{ Name: s.config.Name, diff --git a/src/css/sw-projects-surface.css b/src/css/sw-projects-surface.css new file mode 100644 index 0000000..efe2ff7 --- /dev/null +++ b/src/css/sw-projects-surface.css @@ -0,0 +1,671 @@ +/* ========================================== + Chat Switchboard — Projects Surface CSS + ========================================== + v0.37.16: Card grid list + detail with inline chat. + sw-projects__ prefixed, BEM convention. + ========================================== */ + +/* ── Root ────────────────────────────────── */ + +.sw-projects { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg, #0e0e10); +} + +/* ── List View ───────────────────────────── */ + +.sw-projects__list { + flex: 1; + overflow-y: auto; + padding: 24px 32px 40px; + max-width: 940px; + margin: 0 auto; + width: 100%; +} + +.sw-projects__list-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.sw-projects__list-header .sw-projects__new-btn { + margin-left: auto; +} + +.sw-projects__list-title { + font-size: 22px; + font-weight: 600; + color: var(--text, #e8e8ed); + margin: 0; +} + +.sw-projects__new-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: var(--bg-raised, #222227); + border: 1px solid var(--border, #2e2e35); + border-radius: 8px; + color: var(--text, #e8e8ed); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.sw-projects__new-btn:hover { + background: var(--bg-hover, #2a2a30); + border-color: var(--border-light, #3a3a42); +} + +.sw-projects__search { + width: 100%; + padding: 10px 14px 10px 36px; + border: 1px solid var(--border, #2e2e35); + border-radius: 10px; + background: var(--bg-surface, #18181b); + color: var(--text, #e8e8ed); + font-size: 14px; + margin-bottom: 8px; + outline: none; + transition: border-color 0.15s; +} + +.sw-projects__search:focus { + border-color: var(--accent, #6c9fff); +} + +.sw-projects__search-wrap { + position: relative; +} + +.sw-projects__search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text-3, #6b6b7b); + font-size: 14px; + pointer-events: none; +} + +.sw-projects__sort { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 6px; + margin-bottom: 16px; + font-size: 13px; + color: var(--text-3, #6b6b7b); +} + +.sw-projects__sort select { + background: var(--bg-raised, #222227); + border: 1px solid var(--border, #2e2e35); + border-radius: 6px; + color: var(--text-2, #9898a8); + font-size: 13px; + padding: 4px 8px; + cursor: pointer; +} + +/* ── Card Grid ───────────────────────────── */ + +.sw-projects__grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.sw-projects__card { + background: var(--bg-surface, #18181b); + border: 1px solid var(--border, #2e2e35); + border-radius: 12px; + padding: 20px; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; + display: flex; + flex-direction: column; + min-height: 120px; +} + +.sw-projects__card:hover { + border-color: var(--border-light, #3a3a42); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.sw-projects__card-name { + font-size: 15px; + font-weight: 600; + color: var(--text, #e8e8ed); + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; +} + +.sw-projects__card-desc { + font-size: 13px; + color: var(--text-2, #9898a8); + flex: 1; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + line-height: 1.5; +} + +.sw-projects__card-meta { + font-size: 12px; + color: var(--text-3, #6b6b7b); + margin-top: 12px; +} + +.sw-projects__card-badge { + display: inline-flex; + padding: 2px 8px; + font-size: 11px; + font-weight: 500; + border-radius: 4px; + background: var(--bg-raised, #222227); + border: 1px solid var(--border, #2e2e35); + color: var(--text-3, #6b6b7b); +} + +.sw-projects__empty { + text-align: center; + padding: 60px 20px; + color: var(--text-3, #6b6b7b); + font-size: 14px; +} + + +/* ── Detail View ─────────────────────────── */ + +.sw-projects__detail { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.sw-projects__detail-header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + border-bottom: 1px solid var(--border, #2e2e35); + flex-shrink: 0; + min-height: 48px; +} + +.sw-projects__back { + font-size: 13px; + color: var(--text-3, #6b6b7b); + cursor: pointer; + text-decoration: none; + white-space: nowrap; + transition: color 0.15s; +} + +.sw-projects__back:hover { + color: var(--text, #e8e8ed); +} + +.sw-projects__detail-name { + font-size: 17px; + font-weight: 600; + color: var(--text, #e8e8ed); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; +} + +.sw-projects__detail-name-input { + font-size: 17px; + font-weight: 600; + color: var(--text, #e8e8ed); + background: var(--bg-surface, #18181b); + border: 1px solid var(--accent, #6c9fff); + border-radius: 6px; + padding: 2px 8px; + flex: 1; + min-width: 0; + outline: none; +} + +.sw-projects__header-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.sw-projects__icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--text-3, #6b6b7b); + cursor: pointer; + transition: background 0.15s, color 0.15s; + font-size: 16px; +} + +.sw-projects__icon-btn:hover { + background: var(--bg-hover, #2a2a30); + color: var(--text, #e8e8ed); +} + +.sw-projects__icon-btn--active { + color: var(--warning, #eab308); +} + +/* ── Detail Body (2-column) ──────────────── */ + +.sw-projects__detail-body { + display: flex; + flex: 1; + overflow: hidden; +} + +.sw-projects__conversations { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + min-width: 0; +} + +.sw-projects__conv-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + flex-shrink: 0; +} + +.sw-projects__conv-list { + flex: 1; + overflow-y: auto; + padding: 0 12px 20px; +} + +.sw-projects__conv-item { + display: flex; + flex-direction: column; + padding: 12px; + border-radius: 8px; + cursor: pointer; + transition: background 0.12s; +} + +.sw-projects__conv-item:hover { + background: var(--bg-hover, #2a2a30); +} + +.sw-projects__conv-title { + font-size: 14px; + font-weight: 500; + color: var(--text, #e8e8ed); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sw-projects__conv-meta { + font-size: 12px; + color: var(--text-3, #6b6b7b); + margin-top: 4px; +} + +.sw-projects__conv-empty { + padding: 40px 20px; + text-align: center; + color: var(--text-3, #6b6b7b); + font-size: 13px; +} + +/* ── Chat Embed ──────────────────────────── */ + +.sw-projects__chat-wrap { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.sw-projects__chat-back { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + font-size: 12px; + color: var(--text-3, #6b6b7b); + cursor: pointer; + border-bottom: 1px solid var(--border, #2e2e35); + flex-shrink: 0; + transition: color 0.15s; +} + +.sw-projects__chat-back:hover { + color: var(--text, #e8e8ed); +} + +/* ── Right Sidebar ───────────────────────── */ + +.sw-projects__sidebar { + width: 320px; + flex-shrink: 0; + border-left: 1px solid var(--border, #2e2e35); + overflow-y: auto; + padding: 12px 16px; + background: var(--bg-surface, #18181b); +} + +/* ── Collapsible Sections ────────────────── */ + +.sw-projects__section { + margin-bottom: 4px; +} + +.sw-projects__section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 0; + cursor: pointer; + user-select: none; +} + +.sw-projects__section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-2, #9898a8); +} + +.sw-projects__section-count { + font-size: 11px; + color: var(--text-3, #6b6b7b); + margin-left: 6px; +} + +.sw-projects__section-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.sw-projects__section-add { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--text-3, #6b6b7b); + cursor: pointer; + font-size: 16px; + transition: background 0.15s, color 0.15s; +} + +.sw-projects__section-add:hover { + background: var(--bg-hover, #2a2a30); + color: var(--text, #e8e8ed); +} + +.sw-projects__section-chevron { + font-size: 10px; + color: var(--text-3, #6b6b7b); + transition: transform 0.15s; +} + +.sw-projects__section-chevron--open { + transform: rotate(90deg); +} + +.sw-projects__section-body { + padding-bottom: 8px; +} + +.sw-projects__section-textarea { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border, #2e2e35); + border-radius: 8px; + background: var(--bg-raised, #222227); + color: var(--text, #e8e8ed); + font-size: 13px; + line-height: 1.5; + resize: vertical; + min-height: 60px; + outline: none; + transition: border-color 0.15s; + font-family: inherit; +} + +.sw-projects__section-textarea:focus { + border-color: var(--accent, #6c9fff); +} + +.sw-projects__section-hint { + font-size: 12px; + color: var(--text-3, #6b6b7b); + padding: 8px 0; +} + +/* ── Section Items (KBs, Notes, Files) ──── */ + +.sw-projects__section-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 6px; + font-size: 13px; + color: var(--text, #e8e8ed); + transition: background 0.12s; +} + +.sw-projects__section-item:hover { + background: var(--bg-hover, #2a2a30); +} + +.sw-projects__section-item-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sw-projects__section-item-meta { + font-size: 11px; + color: var(--text-3, #6b6b7b); + flex-shrink: 0; +} + +.sw-projects__section-item-remove { + display: none; + font-size: 11px; + color: var(--danger, #ef4444); + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + background: transparent; + border: none; + transition: background 0.15s; +} + +.sw-projects__section-item:hover .sw-projects__section-item-remove { + display: inline-flex; +} + +.sw-projects__section-item-remove:hover { + background: var(--danger-dim, rgba(239, 68, 68, 0.2)); +} + +/* ── File Drop Zone ──────────────────────── */ + +.sw-projects__dropzone { + border: 2px dashed var(--border, #2e2e35); + border-radius: 8px; + padding: 20px; + text-align: center; + color: var(--text-3, #6b6b7b); + font-size: 13px; + transition: border-color 0.15s, background 0.15s; + cursor: pointer; +} + +.sw-projects__dropzone:hover { + border-color: var(--border-light, #3a3a42); +} + +.sw-projects__dropzone--active { + border-color: var(--accent, #6c9fff); + background: var(--accent-dim, rgba(108, 159, 255, 0.12)); + color: var(--text, #e8e8ed); +} + +/* ── Picker Dropdown ─────────────────────── */ + +.sw-projects__picker { + position: absolute; + right: 0; + top: 100%; + z-index: 10; + width: 280px; + margin-top: 4px; + padding: 6px; + border: 1px solid var(--border, #2e2e35); + border-radius: 8px; + background: var(--bg-raised, #222227); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + max-height: 240px; + overflow-y: auto; +} + +.sw-projects__picker-item { + padding: 6px 10px; + border-radius: 6px; + font-size: 13px; + color: var(--text, #e8e8ed); + cursor: pointer; + transition: background 0.12s; +} + +.sw-projects__picker-item:hover { + background: var(--bg-hover, #2a2a30); +} + +.sw-projects__picker-hint { + padding: 8px 10px; + font-size: 12px; + color: var(--text-3, #6b6b7b); +} + +/* ── Context Menu (... menu) ─────────────── */ + +.sw-projects__ctx-menu { + position: absolute; + right: 0; + top: 100%; + z-index: 20; + min-width: 180px; + padding: 6px; + border: 1px solid var(--border, #2e2e35); + border-radius: 8px; + background: var(--bg-raised, #222227); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} + +.sw-projects__ctx-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; + color: var(--text, #e8e8ed); + cursor: pointer; + transition: background 0.12s; + border: none; + background: none; + width: 100%; + text-align: left; +} + +.sw-projects__ctx-item:hover { + background: var(--bg-hover, #2a2a30); +} + +.sw-projects__ctx-item--danger { + color: var(--danger, #ef4444); +} + +.sw-projects__ctx-divider { + height: 1px; + background: var(--border, #2e2e35); + margin: 4px 0; +} + +/* ── Color Swatches (in menu) ────────────── */ + +.sw-projects__color-row { + display: flex; + gap: 4px; + padding: 8px 12px; +} + +.sw-projects__color-swatch { + width: 20px; + height: 20px; + border-radius: 50%; + cursor: pointer; + border: 2px solid transparent; + transition: border-color 0.15s; +} + +.sw-projects__color-swatch--active { + border-color: var(--text, #e8e8ed); +} + +/* ── Responsive ──────────────────────────── */ + +@media (max-width: 768px) { + .sw-projects__grid { + grid-template-columns: 1fr; + } + + .sw-projects__list { + padding: 16px; + } + + .sw-projects__detail-body { + flex-direction: column; + } + + .sw-projects__sidebar { + width: 100%; + border-left: none; + border-top: 1px solid var(--border, #2e2e35); + max-height: 40vh; + } +} diff --git a/src/js/sw/shell/user-menu.js b/src/js/sw/shell/user-menu.js index e234c7c..f60095c 100644 --- a/src/js/sw/shell/user-menu.js +++ b/src/js/sw/shell/user-menu.js @@ -39,7 +39,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) { if (!sw?.api?.surfaces?.list) return; sw.api.surfaces.list().then(data => { const all = data || []; - const CORE = new Set(['chat', 'admin', 'notes', 'settings', 'team-admin', 'workflow', 'workflow-landing']); + const CORE = new Set(['chat', 'admin', 'notes', 'settings', 'team-admin', 'projects', 'workflow', 'workflow-landing']); setExtSurfaces(all.filter(s => !CORE.has(s.id))); }).catch(() => {}); }, [authenticated]); @@ -53,6 +53,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) { const surfaces = [ { key: 'chat', label: 'Chat', icon: '\ud83d\udcac' }, { key: 'notes', label: 'Notes', icon: '\ud83d\udcdd' }, + { key: 'projects', label: 'Projects', icon: '\ud83d\udcc1' }, ]; // Add extension surfaces fetched from API diff --git a/src/js/sw/surfaces/projects/detail.js b/src/js/sw/surfaces/projects/detail.js new file mode 100644 index 0000000..fa431f6 --- /dev/null +++ b/src/js/sw/surfaces/projects/detail.js @@ -0,0 +1,429 @@ +/** + * ProjectDetail — two-column detail view with inline chat + metadata sidebar. + * + * Props: + * project — project object + * onBack — () => void (return to list) + * onUpdate — (deleted?: boolean) => void + * + * Left panel toggles: conversation list ↔ embedded ChatPane. + * Right sidebar: collapsible sections (Description, Instructions, KBs, Notes, Files). + */ +const html = window.html; +const h = window.preact.h; +const { useState, useEffect, useCallback, useRef } = window.hooks; + +import { ChatPane } from '../../components/chat-pane/index.js'; +import { UserMenu } from '../../shell/user-menu.js'; + +const COLORS = ['#4f8cff','#34d399','#f59e0b','#ef4444','#a78bfa','#ec4899','#06b6d4','#8b5cf6']; + +function fmtDate(d) { + if (!d) return '\u2014'; + return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); +} +function formatBytes(b) { + if (!b) return '0 B'; + if (b < 1024) return b + ' B'; + if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'; + return (b / 1048576).toFixed(1) + ' MB'; +} +function relTime(dateStr) { + if (!dateStr) return ''; + const DIVS = [[60,'second'],[60,'minute'],[24,'hour'],[30,'day'],[12,'month'],[Infinity,'year']]; + let val = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); + if (val < 10) return 'just now'; + for (const [div, unit] of DIVS) { + if (val < div) { const n = Math.floor(val); return `${n} ${unit}${n!==1?'s':''} ago`; } + val /= div; + } + return ''; +} + +export function ProjectDetail({ project, onBack, onUpdate }) { + const BASE = window.__BASE__ || ''; + const chatRef = useRef(null); + + // ── State ── + const [activeChannelId, setActiveChannelId] = useState(null); + const [assoc, setAssoc] = useState({ channels: [], kbs: [], notes: [], files: [] }); + const [editingName, setEditingName] = useState(false); + const [nameVal, setNameVal] = useState(project.name || ''); + const [menuOpen, setMenuOpen] = useState(false); + const [sections, setSections] = useState({ desc: true, instructions: false, kbs: false, notes: false, files: false }); + const [desc, setDesc] = useState(project.description || ''); + const [instructions, setInstructions] = useState(project.settings?.instructions || ''); + const [descDirty, setDescDirty] = useState(false); + const [instrDirty, setInstrDirty] = useState(false); + const [starred, setStarred] = useState(!!project.settings?.starred); + + // Picker state + const [pickerOpen, setPickerOpen] = useState(''); + const [pickerItems, setPickerItems] = useState([]); + const [pickerLoading, setPickerLoading] = useState(false); + + // Drag state for files + const [dragActive, setDragActive] = useState(false); + + // ── Load associations ── + const loadAssoc = useCallback(async () => { + const id = project.id; + const [ch, kb, nt, fi] = await Promise.all([ + sw.api.projects.channels(id).then(r => r?.data || r || []).catch(() => []), + sw.api.projects.kbs(id).then(r => r?.data || r || []).catch(() => []), + sw.api.projects.notes(id).then(r => r?.data || r || []).catch(() => []), + sw.api.projects.files(id).then(r => r?.data || r?.files || r || []).catch(() => []), + ]); + setAssoc({ channels: ch, kbs: kb, notes: nt, files: fi }); + }, [project.id]); + + useEffect(() => { loadAssoc(); }, [loadAssoc]); + + // ── Toggle section ── + function toggleSection(key) { + setSections(prev => ({ ...prev, [key]: !prev[key] })); + } + + // ── Name editing ── + async function saveName() { + const name = nameVal.trim(); + if (!name || name === project.name) { setEditingName(false); return; } + try { + await sw.api.projects.update(project.id, { name }); + sw.toast('Renamed', 'success'); + setEditingName(false); + onUpdate(); + } catch (e) { sw.toast(e.message, 'error'); } + } + + // ── Description / Instructions save ── + async function saveDesc() { + try { + await sw.api.projects.update(project.id, { description: desc }); + sw.toast('Description saved', 'success'); + setDescDirty(false); + onUpdate(); + } catch (e) { sw.toast(e.message, 'error'); } + } + + async function saveInstructions() { + try { + const settings = { ...(project.settings || {}), instructions }; + await sw.api.projects.update(project.id, { settings }); + sw.toast('Instructions saved', 'success'); + setInstrDirty(false); + onUpdate(); + } catch (e) { sw.toast(e.message, 'error'); } + } + + // ── Star toggle ── + async function toggleStar() { + const next = !starred; + setStarred(next); + try { + const settings = { ...(project.settings || {}), starred: next }; + await sw.api.projects.update(project.id, { settings }); + } catch (e) { sw.toast(e.message, 'error'); setStarred(!next); } + } + + // ── Context menu actions ── + async function handleColor(c) { + try { + await sw.api.projects.update(project.id, { color: c }); + sw.toast('Color updated', 'success'); + setMenuOpen(false); + onUpdate(); + } catch (e) { sw.toast(e.message, 'error'); } + } + + async function toggleArchive() { + try { + await sw.api.projects.update(project.id, { is_archived: !project.is_archived }); + sw.toast(project.is_archived ? 'Unarchived' : 'Archived', 'success'); + setMenuOpen(false); + onUpdate(); + } catch (e) { sw.toast(e.message, 'error'); } + } + + async function deleteProject() { + setMenuOpen(false); + if (!await sw.confirm('Delete this project? This cannot be undone.', true)) return; + try { + await sw.api.projects.del(project.id); + sw.toast('Project deleted', 'success'); + onUpdate(true); + } catch (e) { sw.toast(e.message, 'error'); } + } + + // ── Picker helpers ── + async function openPicker(type) { + if (pickerOpen === type) { setPickerOpen(''); return; } + setPickerOpen(type); + setSections(prev => ({ ...prev, [type]: true })); + setPickerLoading(true); + setPickerItems([]); + try { + let items = []; + if (type === 'kbs') { + const r = await sw.api.knowledge.list(); + const all = r?.data || r || []; + const attached = new Set(assoc.kbs.map(k => k.id || k.kb_id)); + items = all.filter(k => !attached.has(k.id)); + } else if (type === 'notes') { + const r = await sw.api.notes.list(); + const all = r?.data || r || []; + const attached = new Set(assoc.notes.map(n => n.id || n.note_id)); + items = all.filter(n => !attached.has(n.id)); + } + setPickerItems(items); + } catch (e) { sw.toast(e.message, 'error'); } + setPickerLoading(false); + } + + async function pickItem(type, item) { + try { + if (type === 'kbs') await sw.api.projects.addKb(project.id, item.id, false); + else if (type === 'notes') await sw.api.projects.addNote(project.id, item.id); + sw.toast('Added', 'success'); + setPickerOpen(''); + loadAssoc(); + onUpdate(); + } catch (e) { sw.toast(e.message, 'error'); } + } + + // ── Remove handlers ── + async function removeKb(kbId) { + try { await sw.api.projects.removeKb(project.id, kbId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); } + catch (e) { sw.toast(e.message, 'error'); } + } + async function removeNote(noteId) { + try { await sw.api.projects.removeNote(project.id, noteId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); } + catch (e) { sw.toast(e.message, 'error'); } + } + async function removeChannel(chId) { + try { await sw.api.projects.removeChannel(project.id, chId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); } + catch (e) { sw.toast(e.message, 'error'); } + } + + // ── File upload ── + async function uploadFiles(files) { + for (const file of files) { + try { + await sw.api.projects.uploadFile(project.id, file); + sw.toast(`Uploaded ${file.name}`, 'success'); + } catch (e) { sw.toast(e.message, 'error'); } + } + loadAssoc(); + } + + function handleUploadClick() { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.onchange = () => { if (input.files.length) uploadFiles(input.files); }; + input.click(); + } + + function handleDrop(e) { + e.preventDefault(); + setDragActive(false); + if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); + } + + // ── New conversation ── + async function newConversation() { + try { + const ch = await sw.api.channels.create({ type: 'direct', title: 'New Chat' }); + const chId = ch?.id || ch?.data?.id; + if (chId) { + await sw.api.projects.addChannel(project.id, chId); + await loadAssoc(); + setActiveChannelId(chId); + } + } catch (e) { sw.toast(e.message, 'error'); } + } + + // ── Render: Picker dropdown ── + function renderPicker(type, emptyHint) { + if (pickerOpen !== type) return null; + return html`
+ ${pickerLoading && html`
Loading\u2026
`} + ${!pickerLoading && pickerItems.length === 0 && html`
${emptyHint}
`} + ${!pickerLoading && pickerItems.map(item => html` +
pickItem(type, item)}> + ${item.title || item.name || item.id} +
+ `)} +
`; + } + + // ── Render: Collapsible section ── + function renderSection(key, title, count, content, addAction) { + const open = sections[key]; + return html`
+
{ if (e.target.closest('.sw-projects__section-add')) return; toggleSection(key); }}> +
+ ${title} + ${count != null && html`${count}`} +
+
+ ${addAction && html``} + \u25B6 +
+
+ ${open && html`
${content}
`} +
`; + } + + // ── Render: Left panel ── + function renderLeftPanel() { + if (activeChannelId) { + return html`
+
setActiveChannelId(null)}> + \u2190 Back to conversations +
+ <${ChatPane} + channelId=${activeChannelId} + standalone=${false} + handleRef=${chatRef} + className="sw-projects__chat-pane" /> +
`; + } + + return html`
+
+ Conversations + +
+
+ ${assoc.channels.length === 0 && html` +
+ No conversations yet.
+ +
+ `} + ${assoc.channels.map(ch => { + const chId = ch.id || ch.channel_id; + return html`
setActiveChannelId(chId)}> +
${ch.title || ch.name || chId}
+
Last message ${relTime(ch.updated_at)}
+
`; + })} +
+
`; + } + + // ── Render: Right sidebar ── + function renderSidebar() { + return html`
+ ${renderSection('desc', 'Description', null, html` +