Changeset 0.37.16 (#228)

This commit is contained in:
2026-03-24 11:46:13 +00:00
parent d005e8a30f
commit e0687d2ea6
19 changed files with 2900 additions and 13 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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 = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
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`
<div class="gb-shell">
<header class="gb-header">
<div class="gb-header__left">
<h1 class="gb-title">Git Board</h1>
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
</div>
<div class="gb-header__right">
<button class="btn-small" onClick=${loadBoard} disabled=${loading}>
${loading ? '↻' : 'Refresh'}
</button>
<div ref=${menuRef}></div>
</div>
</header>
${board ? html`<${Board} data=${board} />` : html`
<div class="gb-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
`}
</div>
`;
}
// ── 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`
<div class="gb-shell">
<header class="gb-header">
<div class="gb-header__left"><h1 class="gb-title">Git Board</h1></div>
<div class="gb-header__right"><div ref=${menuRef}></div></div>
</header>
<div class="gb-setup">
<h2>Connect Your Account</h2>
<p>Enter your personal access token for Gitea or GitHub.<br/>
The admin configures the platform URL. You provide your own token.</p>
<div class="gb-setup__form">
<input type="password" class="gb-setup__input"
placeholder="ghp_... or gitea token"
value=${token} onInput=${function (e) { setToken(e.target.value); }} />
<button class="btn-small btn-primary" onClick=${save} disabled=${saving || !token.trim()}>
${saving ? 'Saving…' : 'Save Token'}
</button>
</div>
<p class="gb-setup__hint">Token is stored in your personal extension settings — not shared with other users.</p>
</div>
</div>
`;
}
// ── Repo Picker ────────────────────────────
function RepoPicker({ repos, owner, repo, onSelect }) {
var current = owner + '/' + repo;
return html`
<select class="gb-repo-picker" value=${current}
onChange=${function (e) {
var parts = e.target.value.split('/');
onSelect(parts[0], parts.slice(1).join('/'));
}}>
${repos.map(function (r) {
return html`<option key=${r.full_name} value=${r.full_name}>${r.full_name}</option>`;
})}
</select>
`;
}
// ── 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`
<div class="gb-board">
<${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} />`; })}
<//>
</div>
`;
}
function Column({ title, count, children }) {
return html`
<div class="gb-column">
<div class="gb-column__header">
<span class="gb-column__title">${title}</span>
<span class="gb-column__count">${count || 0}</span>
</div>
<div class="gb-column__cards">${children}</div>
</div>
`;
}
function IssueCard({ issue }) {
return html`
<a class="gb-card" href=${issue.html_url} target="_blank" rel="noopener">
<div class="gb-card__header">
<span class="gb-card__number">#${issue.number}</span>
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</span>`}
</div>
<div class="gb-card__title">${esc(issue.title)}</div>
<div class="gb-card__labels">
${(issue.labels || []).map(function (l) {
return html`<span key=${l} class="badge">${esc(l)}</span>`;
})}
</div>
</a>
`;
}
function PRCard({ pr }) {
return html`
<a class="gb-card gb-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
<div class="gb-card__header">
<span class="gb-card__number">#${pr.number}</span>
<span class="gb-card__branch">${esc(pr.head)}${esc(pr.base)}</span>
</div>
<div class="gb-card__title">${esc(pr.title)}</div>
<div class="gb-card__meta">
<span>@${esc(pr.user)}</span>
<span>${timeAgo(pr.created_at)}</span>
</div>
</a>
`;
}
// ── 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');
})();

View File

@@ -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": ""
}
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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",

View File

@@ -27,6 +27,7 @@
<link rel="stylesheet" href="{{.BasePath}}/css/sw-shell.css?v={{.Version}}">
{{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")}}
<link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}">
@@ -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}}<div style="padding:20px">Unknown surface: {{.Surface}}</div>
{{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")}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>

View File

@@ -0,0 +1,32 @@
{{/*
Projects surface — v0.37.16: Card grid + detail with inline chat.
*/}}
{{define "surface-projects"}}
<div id="projects-mount" style="height:100%;overflow:hidden;"></div>
{{end}}
{{define "css-projects"}}
<link rel="stylesheet" href="{{.BasePath}}/css/sw-projects-surface.css?v={{.Version}}">
{{end}}
{{define "scripts-projects"}}
<script nonce="{{.CSPNonce}}">
window.__PROJECT_ID__ = '{{.Data.ProjectID}}';
</script>
<script type="module" nonce="{{.CSPNonce}}">
const { h, render } = await import('{{.BasePath}}/js/sw/vendor/preact.module.js');
const hooksModule = await import('{{.BasePath}}/js/sw/vendor/hooks.module.js');
const { default: htm } = await import('{{.BasePath}}/js/sw/vendor/htm.module.js');
const html = htm.bind(h);
window.preact = window.preact || { h, render };
window.hooks = window.hooks || hooksModule;
window.html = window.html || html;
const { boot } = await import('{{.BasePath}}/js/sw/sdk/index.js?v={{.Version}}');
await boot();
await import('{{.BasePath}}/js/sw/surfaces/projects/index.js?v={{.Version}}');
</script>
{{end}}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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`<div class="sw-projects__picker">
${pickerLoading && html`<div class="sw-projects__picker-hint">Loading\u2026</div>`}
${!pickerLoading && pickerItems.length === 0 && html`<div class="sw-projects__picker-hint">${emptyHint}</div>`}
${!pickerLoading && pickerItems.map(item => html`
<div key=${item.id} class="sw-projects__picker-item" onClick=${() => pickItem(type, item)}>
${item.title || item.name || item.id}
</div>
`)}
</div>`;
}
// ── Render: Collapsible section ──
function renderSection(key, title, count, content, addAction) {
const open = sections[key];
return html`<div class="sw-projects__section">
<div class="sw-projects__section-header" onClick=${e => { if (e.target.closest('.sw-projects__section-add')) return; toggleSection(key); }}>
<div>
<span class="sw-projects__section-title">${title}</span>
${count != null && html`<span class="sw-projects__section-count">${count}</span>`}
</div>
<div class="sw-projects__section-actions">
${addAction && html`<button class="sw-projects__section-add"
onClick=${e => { e.stopPropagation(); addAction(); }}>+</button>`}
<span class=${'sw-projects__section-chevron' + (open ? ' sw-projects__section-chevron--open' : '')}>\u25B6</span>
</div>
</div>
${open && html`<div class="sw-projects__section-body">${content}</div>`}
</div>`;
}
// ── Render: Left panel ──
function renderLeftPanel() {
if (activeChannelId) {
return html`<div class="sw-projects__chat-wrap">
<div class="sw-projects__chat-back" onClick=${() => setActiveChannelId(null)}>
\u2190 Back to conversations
</div>
<${ChatPane}
channelId=${activeChannelId}
standalone=${false}
handleRef=${chatRef}
className="sw-projects__chat-pane" />
</div>`;
}
return html`<div class="sw-projects__conversations">
<div class="sw-projects__conv-header">
<span style="font-size:13px;font-weight:600;color:var(--text-2);">Conversations</span>
<button class="sw-projects__new-btn" style="font-size:12px;padding:5px 12px;" onClick=${newConversation}>+ New</button>
</div>
<div class="sw-projects__conv-list">
${assoc.channels.length === 0 && html`
<div class="sw-projects__conv-empty">
No conversations yet.<br/>
<button class="sw-projects__new-btn" style="margin-top:12px;font-size:12px;padding:5px 12px;" onClick=${newConversation}>Start a conversation</button>
</div>
`}
${assoc.channels.map(ch => {
const chId = ch.id || ch.channel_id;
return html`<div key=${chId} class="sw-projects__conv-item" onClick=${() => setActiveChannelId(chId)}>
<div class="sw-projects__conv-title">${ch.title || ch.name || chId}</div>
<div class="sw-projects__conv-meta">Last message ${relTime(ch.updated_at)}</div>
</div>`;
})}
</div>
</div>`;
}
// ── Render: Right sidebar ──
function renderSidebar() {
return html`<div class="sw-projects__sidebar">
${renderSection('desc', 'Description', null, html`
<textarea class="sw-projects__section-textarea" rows="3"
value=${desc}
onInput=${e => { setDesc(e.target.value); setDescDirty(true); }}
placeholder="Add a project description\u2026" />
${descDirty && html`<button class="sw-projects__new-btn" style="margin-top:6px;font-size:12px;padding:4px 10px;" onClick=${saveDesc}>Save</button>`}
`)}
${renderSection('instructions', 'Instructions', null, html`
<textarea class="sw-projects__section-textarea" rows="4"
value=${instructions}
onInput=${e => { setInstructions(e.target.value); setInstrDirty(true); }}
placeholder="Add instructions to tailor responses\u2026" />
${instrDirty && html`<button class="sw-projects__new-btn" style="margin-top:6px;font-size:12px;padding:4px 10px;" onClick=${saveInstructions}>Save</button>`}
`)}
${renderSection('kbs', 'Knowledge Bases', assoc.kbs.length, html`
${assoc.kbs.length === 0 && html`<div class="sw-projects__section-hint">No knowledge bases linked.</div>`}
${assoc.kbs.map(kb => html`
<div key=${kb.id || kb.kb_id} class="sw-projects__section-item">
<span class="sw-projects__section-item-name">${kb.name || kb.kb_id}</span>
<button class="sw-projects__section-item-remove" onClick=${() => removeKb(kb.id || kb.kb_id)}>Remove</button>
</div>
`)}
${renderPicker('kbs', 'No knowledge bases available.')}
`, () => openPicker('kbs'))}
${renderSection('notes', 'Notes', assoc.notes.length, html`
${assoc.notes.length === 0 && html`<div class="sw-projects__section-hint">No notes linked.</div>`}
${assoc.notes.map(n => html`
<div key=${n.id || n.note_id} class="sw-projects__section-item">
<span class="sw-projects__section-item-name">${n.title || n.note_id}</span>
<span class="sw-projects__section-item-meta">${fmtDate(n.created_at || n.added_at)}</span>
<button class="sw-projects__section-item-remove" onClick=${() => removeNote(n.id || n.note_id)}>Remove</button>
</div>
`)}
${renderPicker('notes', 'No notes available.')}
`, () => openPicker('notes'))}
${renderSection('files', 'Files', assoc.files.length, html`
${assoc.files.map(f => html`
<div key=${f.id} class="sw-projects__section-item">
<span class="sw-projects__section-item-name">${f.filename || f.name}</span>
<span class="sw-projects__section-item-meta">${formatBytes(f.size_bytes || f.size)}</span>
</div>
`)}
<div class=${'sw-projects__dropzone' + (dragActive ? ' sw-projects__dropzone--active' : '')}
onClick=${handleUploadClick}
onDragOver=${e => { e.preventDefault(); setDragActive(true); }}
onDragLeave=${() => setDragActive(false)}
onDrop=${handleDrop}>
${dragActive ? 'Drop files here' : 'Add PDFs, documents, or other text to reference in this project.'}
</div>
`, handleUploadClick)}
</div>`;
}
// ── Main render ──
return html`<div class="sw-projects__detail">
<div class="sw-projects__detail-header">
<${UserMenu} placement="down-right" />
<a class="sw-projects__back" onClick=${onBack}>\u2190 All projects</a>
${editingName
? html`<input class="sw-projects__detail-name-input"
value=${nameVal}
onInput=${e => setNameVal(e.target.value)}
onBlur=${saveName}
onKeyDown=${e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') setEditingName(false); }}
autoFocus />`
: html`<h2 class="sw-projects__detail-name"
onClick=${() => { setNameVal(project.name || ''); setEditingName(true); }}>
${(project.icon ? project.icon + ' ' : '') + project.name}
</h2>`
}
<div class="sw-projects__header-actions">
<button class=${'sw-projects__icon-btn' + (starred ? ' sw-projects__icon-btn--active' : '')}
onClick=${toggleStar} title="Star project">
${starred ? '\u2605' : '\u2606'}
</button>
<div style="position:relative">
<button class="sw-projects__icon-btn"
onClick=${() => setMenuOpen(!menuOpen)} title="More options">\u22EF</button>
${menuOpen && html`
<div class="sw-projects__ctx-menu">
<button class="sw-projects__ctx-item"
onClick=${() => { setMenuOpen(false); setNameVal(project.name || ''); setEditingName(true); }}>Rename</button>
<div class="sw-projects__color-row">
${COLORS.map(c => html`
<span key=${c}
class=${'sw-projects__color-swatch' + (project.color === c ? ' sw-projects__color-swatch--active' : '')}
style=${'background:' + c}
onClick=${() => handleColor(c)} />
`)}
</div>
<div class="sw-projects__ctx-divider" />
<button class="sw-projects__ctx-item" onClick=${toggleArchive}>
${project.is_archived ? 'Unarchive' : 'Archive'}</button>
<button class="sw-projects__ctx-item sw-projects__ctx-item--danger"
onClick=${deleteProject}>Delete</button>
</div>
`}
</div>
</div>
</div>
<div class="sw-projects__detail-body">
${renderLeftPanel()}
${renderSidebar()}
</div>
</div>`;
}

View File

@@ -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`
<div class="sw-projects">
${selected
? html`<${ProjectDetail}
key=${selectedId}
project=${selected}
onBack=${goBack}
onUpdate=${handleUpdate} />`
: html`<${ProjectsList}
projects=${projects}
loading=${loading}
onSelect=${selectProject}
onCreate=${createProject} />`
}
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
// ── Mount ────────────────────────────────
const mount = document.getElementById('projects-mount');
if (mount) {
render(html`<${ProjectsSurface} />`, mount);
console.log('[projects] Surface mounted');
}
export { ProjectsSurface };

View File

@@ -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`<div class="sw-projects__list">
<div class="sw-projects__empty">Loading projects\u2026</div>
</div>`;
}
return html`
<div class="sw-projects__list">
<div class="sw-projects__list-header">
<${UserMenu} placement="down-right" />
<h1 class="sw-projects__list-title">Projects</h1>
<button class="sw-projects__new-btn" onClick=${handleCreate}>+ New project</button>
</div>
<div class="sw-projects__search-wrap">
<span class="sw-projects__search-icon">\u{1F50D}</span>
<input class="sw-projects__search"
type="text"
placeholder="Search projects\u2026"
value=${search}
onInput=${e => setSearch(e.target.value)} />
</div>
<div class="sw-projects__sort">
<span>Sort by</span>
<select value=${sort} onChange=${e => setSort(e.target.value)}>
<option value="activity">Activity</option>
<option value="name">Name</option>
</select>
</div>
${filtered.length === 0 && !loading && html`
<div class="sw-projects__empty">
${search ? 'No projects match your search.' : 'No projects yet.'}
${!search && html`<div style="margin-top:12px;">
<button class="sw-projects__new-btn" onClick=${handleCreate}>+ Create your first project</button>
</div>`}
</div>
`}
<div class="sw-projects__grid">
${filtered.map(p => html`
<div key=${p.id}
class="sw-projects__card"
style=${p.color ? `border-left: 3px solid ${p.color}` : ''}
onClick=${() => onSelect(p.id)}>
<div class="sw-projects__card-name">
${p.icon ? html`<span>${p.icon}</span>` : ''}
<span>${p.name}</span>
${p.is_archived && html`<span class="sw-projects__card-badge">archived</span>`}
</div>
${p.description && html`
<div class="sw-projects__card-desc">${p.description}</div>
`}
<div class="sw-projects__card-meta">${relativeTime(p.updated_at)}</div>
</div>
`)}
</div>
</div>
`;
}