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`
+
+
+
+ ${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`
+
+
+
+
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`
+
+
+ ${esc(issue.title)}
+
+ ${(issue.labels || []).map(function (l) {
+ return html`${esc(l)}`;
+ })}
+
+
+ `;
+ }
+
+ function PRCard({ pr }) {
+ return html`
+
+
+ ${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`
+
+ ${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`
+
+
+ ${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``;
+ }
+
+ // ── Main render ──
+ return html`
+
+
+ ${renderLeftPanel()}
+ ${renderSidebar()}
+
+
`;
+}
diff --git a/src/js/sw/surfaces/projects/index.js b/src/js/sw/surfaces/projects/index.js
new file mode 100644
index 0000000..76584ce
--- /dev/null
+++ b/src/js/sw/surfaces/projects/index.js
@@ -0,0 +1,93 @@
+/**
+ * ProjectsSurface — root view-switching component.
+ *
+ * No selectedId → card grid list (ProjectsList)
+ * With selectedId → detail view (ProjectDetail)
+ *
+ * Globals: __PROJECT_ID__ (deep-link), __BASE__ (base path)
+ */
+const html = window.html;
+const { useState, useEffect, useCallback } = window.hooks;
+const { render } = window.preact;
+
+import { ToastContainer } from '../../primitives/toast.js';
+import { DialogStack } from '../../shell/dialog-stack.js';
+import { ProjectsList } from './list.js';
+import { ProjectDetail } from './detail.js';
+
+function ProjectsSurface() {
+ const BASE = window.__BASE__ || '';
+ const deepLinkId = window.__PROJECT_ID__ || '';
+
+ const [projects, setProjects] = useState([]);
+ const [selectedId, setSelectedId] = useState(deepLinkId || '');
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ try {
+ const resp = await sw.api.projects.list();
+ const list = resp?.data || resp || [];
+ setProjects(list);
+ if (deepLinkId && !list.find(p => p.id === deepLinkId)) {
+ setSelectedId('');
+ }
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, [deepLinkId]);
+
+ useEffect(() => { load(); }, [load]);
+
+ const selected = projects.find(p => p.id === selectedId) || null;
+
+ function selectProject(id) {
+ setSelectedId(id);
+ history.replaceState(null, '', BASE + '/projects/' + id);
+ }
+
+ function goBack() {
+ setSelectedId('');
+ history.replaceState(null, '', BASE + '/projects');
+ }
+
+ async function createProject(name) {
+ try {
+ const proj = await sw.api.projects.create({ name });
+ sw.toast('Project created', 'success');
+ await load();
+ if (proj?.id) selectProject(proj.id);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ const handleUpdate = useCallback((deleted) => {
+ if (deleted) { setSelectedId(''); history.replaceState(null, '', BASE + '/projects'); }
+ load();
+ }, [load, BASE]);
+
+ return html`
+
+ ${selected
+ ? html`<${ProjectDetail}
+ key=${selectedId}
+ project=${selected}
+ onBack=${goBack}
+ onUpdate=${handleUpdate} />`
+ : html`<${ProjectsList}
+ projects=${projects}
+ loading=${loading}
+ onSelect=${selectProject}
+ onCreate=${createProject} />`
+ }
+
+ <${ToastContainer} />
+ <${DialogStack} />
+ `;
+}
+
+// ── Mount ────────────────────────────────
+const mount = document.getElementById('projects-mount');
+if (mount) {
+ render(html`<${ProjectsSurface} />`, mount);
+ console.log('[projects] Surface mounted');
+}
+
+export { ProjectsSurface };
diff --git a/src/js/sw/surfaces/projects/list.js b/src/js/sw/surfaces/projects/list.js
new file mode 100644
index 0000000..97f054c
--- /dev/null
+++ b/src/js/sw/surfaces/projects/list.js
@@ -0,0 +1,124 @@
+/**
+ * ProjectsList — card grid view for all projects.
+ *
+ * Props:
+ * projects — array of project objects
+ * loading — boolean
+ * onSelect — (id: string) => void
+ * onCreate — (name: string) => void
+ */
+const html = window.html;
+const { useState, useMemo } = window.hooks;
+
+import { UserMenu } from '../../shell/user-menu.js';
+
+// ── Relative time helper ────────────────────
+const DIVISIONS = [
+ [60, 'second'], [60, 'minute'], [24, 'hour'],
+ [30, 'day'], [12, 'month'], [Infinity, 'year'],
+];
+function relativeTime(dateStr) {
+ if (!dateStr) return '';
+ let val = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
+ if (val < 10) return 'just now';
+ for (const [div, unit] of DIVISIONS) {
+ if (val < div) {
+ const n = Math.floor(val);
+ return `Updated ${n} ${unit}${n !== 1 ? 's' : ''} ago`;
+ }
+ val /= div;
+ }
+ return '';
+}
+
+export function ProjectsList({ projects, loading, onSelect, onCreate }) {
+ const [search, setSearch] = useState('');
+ const [sort, setSort] = useState('activity');
+
+ const filtered = useMemo(() => {
+ const q = search.toLowerCase().trim();
+ let list = projects;
+ if (q) {
+ list = list.filter(p =>
+ (p.name || '').toLowerCase().includes(q) ||
+ (p.description || '').toLowerCase().includes(q)
+ );
+ }
+ if (sort === 'activity') {
+ list = [...list].sort((a, b) =>
+ new Date(b.updated_at || 0) - new Date(a.updated_at || 0)
+ );
+ } else {
+ list = [...list].sort((a, b) =>
+ (a.name || '').localeCompare(b.name || '')
+ );
+ }
+ return list;
+ }, [projects, search, sort]);
+
+ async function handleCreate() {
+ const name = await sw.prompt('Project name:');
+ if (name && name.trim()) onCreate(name.trim());
+ }
+
+ if (loading) {
+ return html`
+
Loading projects\u2026
+
`;
+ }
+
+ return html`
+
+
+
+
+ \u{1F50D}
+ setSearch(e.target.value)} />
+
+
+
+ Sort by
+
+
+
+ ${filtered.length === 0 && !loading && html`
+
+ ${search ? 'No projects match your search.' : 'No projects yet.'}
+ ${!search && html`
+
+
`}
+
+ `}
+
+
+ ${filtered.map(p => html`
+
onSelect(p.id)}>
+
+ ${p.icon ? html`${p.icon}` : ''}
+ ${p.name}
+ ${p.is_archived && html`archived`}
+
+ ${p.description && html`
+
${p.description}
+ `}
+
${relativeTime(p.updated_at)}
+
+ `)}
+
+
+ `;
+}