Feat v0.2.5 ui polish dead code (#9)
Some checks failed
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Failing after 5s
CI/CD / test-go-pg (push) Failing after 2m34s
CI/CD / test-sqlite (push) Successful in 2m39s
CI/CD / build-and-deploy (push) Has been skipped

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #9.
This commit is contained in:
2026-03-27 14:16:36 +00:00
committed by xcaliber
parent 389354826b
commit 3cc3360624
34 changed files with 369 additions and 806 deletions

View File

@@ -1,62 +0,0 @@
package database
import (
"strings"
"testing"
)
// ── Additional Test Seed Helpers (v0.15.0) ──
// SeedTestMessage creates a single message in a channel and returns the message ID.
func SeedTestMessage(t *testing.T, channelID, parentID, role, content string) string {
t.Helper()
var id string
var parentPtr *string
if parentID != "" {
parentPtr = &parentID
}
err := DB.QueryRow(`
INSERT INTO messages (channel_id, parent_id, role, content, sibling_index)
VALUES ($1, $2, $3, $4, 0)
RETURNING id
`, channelID, parentPtr, role, content).Scan(&id)
if err != nil {
t.Fatalf("SeedTestMessage: %v", err)
}
return id
}
// SeedTestMessages creates a linear chain of alternating user/assistant messages.
// Returns all message IDs in order. The first message has no parent.
func SeedTestMessages(t *testing.T, channelID string, count int, contentSize int) []string {
t.Helper()
content := strings.Repeat("x", contentSize)
ids := make([]string, 0, count)
parentID := ""
for i := 0; i < count; i++ {
role := "user"
if i%2 == 1 {
role = "assistant"
}
id := SeedTestMessage(t, channelID, parentID, role, content)
ids = append(ids, id)
parentID = id
}
return ids
}
// SeedTestCursor sets the active leaf for a user in a channel.
func SeedTestCursor(t *testing.T, channelID, userID, leafID string) {
t.Helper()
_, err := DB.Exec(`
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
VALUES ($1, $2, $3)
ON CONFLICT (channel_id, user_id)
DO UPDATE SET active_leaf_id = $3, updated_at = NOW()
`, channelID, userID, leafID)
if err != nil {
t.Fatalf("SeedTestCursor: %v", err)
}
}

View File

@@ -43,10 +43,10 @@ func (b *Bus) SetBroadcastHook(fn func(Event)) {
//
// Patterns:
//
// "chat.message.abc123" — exact match
// "chat.message.*" — wildcard: matches chat.message.{anything}
// "chat.*" — wildcard: matches chat.{anything}
// "*" — matches all events
// "workflow.assigned.abc123" — exact match
// "workflow.assigned.*" — wildcard: matches workflow.assigned.{anything}
// "workflow.*" — wildcard: matches workflow.{anything}
// "*" — matches all events
func (b *Bus) Subscribe(pattern string, handler Handler) func() {
b.mu.Lock()
b.seq++

View File

@@ -12,13 +12,13 @@ func TestMatch(t *testing.T) {
label, pattern string
want bool
}{
{"chat.message.abc", "chat.message.abc", true},
{"chat.message.abc", "chat.message.*", true},
{"chat.message.abc", "chat.*", true},
{"chat.message.abc", "*", true},
{"chat.message.abc", "chat.message.xyz", false},
{"chat.message.abc", "channel.message.*", false},
{"chat.message.abc", "chat.message", false},
{"workflow.assigned.abc", "workflow.assigned.abc", true},
{"workflow.assigned.abc", "workflow.assigned.*", true},
{"workflow.assigned.abc", "workflow.*", true},
{"workflow.assigned.abc", "*", true},
{"workflow.assigned.abc", "workflow.assigned.xyz", false},
{"workflow.assigned.abc", "notification.new.*", false},
{"workflow.assigned.abc", "workflow.assigned", false},
{"ping", "ping", true},
{"ping", "pong", false},
{"plugin.hook.pre_completion", "plugin.hook.*", true},
@@ -37,12 +37,12 @@ func TestBusPublishExact(t *testing.T) {
bus := NewBus()
var count int32
bus.Subscribe("chat.message.abc", func(e Event) {
bus.Subscribe("workflow.assigned.abc", func(e Event) {
atomic.AddInt32(&count, 1)
})
bus.Publish(Event{Label: "chat.message.abc", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "chat.message.xyz", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "workflow.assigned.abc", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "workflow.assigned.xyz", Payload: json.RawMessage(`{}`)})
if atomic.LoadInt32(&count) != 1 {
t.Errorf("expected 1 dispatch, got %d", count)
@@ -53,13 +53,13 @@ func TestBusPublishWildcard(t *testing.T) {
bus := NewBus()
var count int32
bus.Subscribe("chat.message.*", func(e Event) {
bus.Subscribe("workflow.assigned.*", func(e Event) {
atomic.AddInt32(&count, 1)
})
bus.Publish(Event{Label: "chat.message.abc", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "chat.message.xyz", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "chat.typing.abc", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "workflow.assigned.abc", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "workflow.assigned.xyz", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "workflow.claimed.abc", Payload: json.RawMessage(`{}`)})
if atomic.LoadInt32(&count) != 2 {
t.Errorf("expected 2 dispatches, got %d", count)
@@ -91,7 +91,7 @@ func TestBusGlobalWildcard(t *testing.T) {
atomic.AddInt32(&count, 1)
})
bus.Publish(Event{Label: "chat.message.abc", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "workflow.assigned.abc", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "system.notify", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "plugin.hook.pre_completion", Payload: json.RawMessage(`{}`)})
@@ -105,8 +105,6 @@ func TestRouteFor(t *testing.T) {
label string
want Direction
}{
{"chat.message.abc", DirLocal}, // chat routes removed in v0.1.0
{"chat.typing.abc", DirLocal}, // chat routes removed in v0.1.0
{"system.notify", DirToClient},
{"plugin.hook.pre_completion", DirLocal},
{"internal.db.write", DirLocal},

View File

@@ -35,10 +35,6 @@ const (
// routeTable defines the default routing for known event prefixes.
// Events not listed default to DirLocal (server-only).
var routeTable = map[string]Direction{
// Chat events
// Channel events
// User/presence
"user.presence": DirToClient,
"user.status": DirToClient,

View File

@@ -250,13 +250,6 @@ func (c *Conn) subscribeToBus() {
return
}
// Don't echo typing events back to the sender
if strings.HasPrefix(e.Label, "chat.typing.") || strings.HasPrefix(e.Label, "channel.typing.") {
if e.SenderID == c.userID && e.ConnID == c.id {
return
}
}
// Room filtering: if event has a room, only send if conn is in that room
if e.Room != "" && !c.rooms[e.Room] {
return

View File

@@ -101,13 +101,13 @@ func (h *PackageHandler) EnablePackage(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
}
// DisablePackage disables a package. Chat and Admin cannot be disabled.
// DisablePackage disables a package. Admin cannot be disabled.
// PUT /api/v1/admin/packages/:id/disable
// PUT /api/v1/admin/surfaces/:id/disable (alias)
func (h *PackageHandler) DisablePackage(c *gin.Context) {
id := c.Param("id")
if id == "chat" || id == "admin" {
if id == "admin" {
c.JSON(http.StatusBadRequest, gin.H{"error": id + " cannot be disabled"})
return
}

View File

@@ -285,7 +285,7 @@ func (h *WorkflowHandler) Publish(c *gin.Context) {
stages = []models.WorkflowStage{}
}
// Snapshot includes persona tool grants at publish time (frozen for running instances)
// Snapshot stages at publish time (frozen for running instances)
type stageSnapshot struct {
models.WorkflowStage
ToolGrants []string `json:"tool_grants,omitempty"`

View File

@@ -142,6 +142,21 @@ func parseAndValidateJWT(tokenString string, jwtSecret string) (*Claims, bool) {
return claims, true
}
// UserIDFromCookie extracts the user ID from the sb_token cookie without
// requiring authentication. Returns "" if no valid token is found.
// Used by unauthenticated routes that want optional user context.
func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
cookie, err := c.Cookie("sb_token")
if err != nil || cookie == "" {
return ""
}
claims, ok := parseAndValidateJWT(cookie, jwtSecret)
if !ok {
return ""
}
return claims.UserID
}
// ─── Auth middleware ─────────────────────────────────────────
// Auth returns a Gin middleware that validates JWT bearer tokens and

View File

@@ -81,19 +81,6 @@ type TeamMember struct {
UserRole string `json:"user_role,omitempty"`
}
// =========================================
// GRANTS
// =========================================
type Grant struct {
ID string `json:"id" db:"id"`
PersonaID string `json:"persona_id" db:"persona_id"`
GrantType string `json:"grant_type" db:"grant_type"`
GrantRef string `json:"grant_ref" db:"grant_ref"`
Config JSONMap `json:"config,omitempty" db:"config"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// PLATFORM POLICIES
var PolicyDefaults = map[string]string{
@@ -106,26 +93,6 @@ var PolicyDefaults = map[string]string{
"default_model": "",
}
// USER MODEL SETTINGS
// HiddenEntry identifies a model+provider pair for bulk visibility operations.
// CompositeModelKey builds the composite key used for per-provider model preferences.
func CompositeModelKey(providerConfigID, modelID string) string {
return providerConfigID + ":" + modelID
}
// CHANNELS
// SESSION PARTICIPANTS (v0.24.3)
// SessionParticipant is an ephemeral identity for anonymous workflow
// channel visitors. Scoped to a single channel, no users row required.
// MESSAGES
// CHANNEL PARTICIPANTS, MODELS, CURSORS
// PERSONA GROUPS (v0.23.0)
// HandleFromName generates a URL-safe @mention handle from a display name.
// "Veronica Sharpe" → "veronica-sharpe"
func HandleFromName(name string) string {
@@ -150,26 +117,6 @@ func HandleFromName(name string) string {
return h
}
// ORGANIZATION
// ProjectPatch holds optional fields for updating a project.
// ProjectChannel represents a channel's membership in a project.
// ProjectKB represents a KB's association with a project.
// ProjectNote represents a note's association with a project.
// NOTES
// NoteLink represents a directed link extracted from [[wikilink]] syntax.
// ExportNoteLink is a note_link with source_note_id included (for export).
// NoteLinkResult represents a backlink — a note that links to a given note.
// NoteGraphNode is a lightweight note representation for graph display.
// NoteGraphEdge is a resolved link between two notes.
// NoteGraphDangling is an unresolved [[link]] reference.
// NoteGraph is the full graph topology for a user's notes.
// ATTACHMENTS
// FILES
// FileOrigin constants
// =========================================
// AUDIT LOG
// =========================================
@@ -187,14 +134,6 @@ type AuditEntry struct {
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// USAGE TRACKING
// MODEL PRICING
// VIEW MODELS (computed, not stored)
// UserModel is the view model returned by the capability resolver.
// Combines catalog entries + Personas for the frontend.
// =========================================
// JSON HELPERS
// =========================================

View File

@@ -4,8 +4,8 @@
//
// templates/base.html — outer shell (banner + surface block + scripts)
// templates/login.html — standalone login page
// templates/components/*.html — reusable partials (model-select, team-select, chat-pane, etc.)
// templates/surfaces/*.html — one per surface (chat, editor, notes, admin, etc.)
// templates/components/*.html — reusable partials (model-select, team-select, etc.)
// templates/surfaces/*.html — one per surface (admin, settings, team-admin, extension)
package pages
import (
@@ -46,12 +46,12 @@ type Engine struct {
// registered in Go at startup. Extension surfaces will be registered from
// manifest files (future).
type SurfaceManifest struct {
ID string `json:"id"` // unique identifier: "chat", "editor", "my-dashboard"
Route string `json:"route"` // primary URL pattern: "/", "/editor/:wsId"
AltRoutes []string `json:"alt_routes"` // additional URL patterns: ["/chat/:chatID"]
Title string `json:"title"` // human-readable: "Chat", "Editor"
Template string `json:"template"` // Go template name: "surface-chat", "surface-editor"
Components []string `json:"components"` // component IDs used: ["chat-pane", "file-tree"]
ID string `json:"id"` // unique identifier: "admin", "settings", "my-dashboard"
Route string `json:"route"` // primary URL pattern: "/", "/admin"
AltRoutes []string `json:"alt_routes"` // additional URL patterns
Title string `json:"title"` // human-readable: "Admin", "Settings"
Template string `json:"template"` // Go template name: "surface-admin", "surface-extension"
Components []string `json:"components"` // component IDs used: ["file-tree"]
DataRequires []string `json:"data_requires"` // data loader keys: ["workspace", "models"]
Scripts []string `json:"scripts"` // JS files (surface-specific, beyond base.html common)
Styles []string `json:"styles"` // CSS files (surface-specific)
@@ -244,6 +244,11 @@ func (e *Engine) registerCoreSurfaces() {
Title: "Workflow Landing", Template: "workflow-landing", Auth: "public",
Layout: "single", Source: "core",
},
{
ID: "welcome", Route: "/welcome",
Title: "Welcome", Template: "surface-welcome", Auth: "authenticated",
Layout: "single", Source: "core",
},
}
}
@@ -433,7 +438,7 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
// PageRouteMiddleware holds middleware handlers for each auth level.
// Passed to RegisterPageRoutes by main.go.
type PageRouteMiddleware struct {
Authenticated gin.HandlerFunc // AuthOrRedirect — for chat, editor, notes, settings
Authenticated gin.HandlerFunc // AuthOrRedirect — for authenticated surfaces
Admin []gin.HandlerFunc // AuthOrRedirect + RequireAdminPage
Session gin.HandlerFunc // AuthOrSession — for workflow
}

View File

@@ -6,6 +6,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/middleware"
)
// SeedSurfaces writes core surface manifests to the registry table.
@@ -48,11 +50,10 @@ func (e *Engine) SeedSurfaces() {
}
// IsSurfaceEnabled checks if a surface is enabled in the registry.
// Chat and Admin are always enabled (system-critical).
// Admin is always enabled (system-critical).
// Returns true if the surface is not found (fail-open for backward compat).
func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
// Chat and Admin cannot be disabled — they're system-critical
if surfaceID == "chat" || surfaceID == "admin" {
if surfaceID == "admin" {
return true
}
if e.stores.Packages == nil {
@@ -92,22 +93,39 @@ func (e *Engine) EnabledSurfaceIDs() []string {
// DefaultSurfaceRedirect returns a handler for GET / that redirects to the
// configured default surface, falling back to the first enabled extension
// surface, then /admin.
// surface, then /welcome.
//
// This route has no auth middleware, so we opportunistically read the JWT
// from the cookie to check user preferences. If the cookie is missing or
// invalid, user preference is skipped (falls through to global default).
func (e *Engine) DefaultSurfaceRedirect() gin.HandlerFunc {
return func(c *gin.Context) {
target := e.resolveDefaultSurface(c.Request.Context())
userID := middleware.UserIDFromCookie(c, e.cfg.JWTSecret)
target := e.resolveDefaultSurface(c.Request.Context(), userID)
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+target)
}
}
// resolveDefaultSurface returns the path to redirect to (without BasePath).
// Priority: configured default_surface → first enabled extension surface → /admin.
func (e *Engine) resolveDefaultSurface(ctx context.Context) string {
// Priority: user preference → global default_surface → first enabled extension → /welcome.
func (e *Engine) resolveDefaultSurface(ctx context.Context, userID string) string {
if e.stores.GlobalConfig == nil || e.stores.Packages == nil {
return "/admin"
return "/welcome"
}
// 1. Check configured default_surface
// 1. Check user preference (default_surface in user settings)
if userID != "" && e.stores.Users != nil {
if user, err := e.stores.Users.GetByID(ctx, userID); err == nil && user != nil {
if id, _ := user.Settings["default_surface"].(string); id != "" {
if path := e.surfacePath(ctx, id); path != "" {
return path
}
// User's chosen surface is missing or disabled — fall through
}
}
}
// 2. Check admin-configured global default_surface
if raw, err := e.stores.GlobalConfig.Get(ctx, "default_surface"); err == nil && raw != nil {
if id, ok := raw["id"].(string); ok && id != "" {
if path := e.surfacePath(ctx, id); path != "" {
@@ -117,21 +135,24 @@ func (e *Engine) resolveDefaultSurface(ctx context.Context) string {
}
}
// 2. First enabled extension surface
surfaces, err := e.stores.Packages.ListEnabledByType(ctx, "surface")
// 3. First enabled extension surface (type "surface" or "full")
allPkgs, err := e.stores.Packages.List(ctx)
if err == nil {
for _, s := range surfaces {
if s.Source == "core" {
for _, s := range allPkgs {
if s.Source == "core" || s.Source == "builtin" {
continue
}
if s.Enabled {
if !s.Enabled {
continue
}
if s.Type == "surface" || s.Type == "full" {
return "/s/" + s.ID
}
}
}
// 3. Fallback
return "/admin"
// 4. Fallback — welcome surface (no extensions installed)
return "/welcome"
}
// surfacePath returns the URL path for a surface ID, or "" if the surface
@@ -156,9 +177,8 @@ func (e *Engine) surfacePath(ctx context.Context, id string) string {
return "/s/" + id
}
// disabledRedirect returns a handler that redirects to /admin.
// Uses /admin (not /) to avoid a redirect loop when the default surface
// is the one being disabled.
// disabledRedirect returns a handler that redirects to /.
// The DefaultSurfaceRedirect handler will resolve to the appropriate surface.
func (e *Engine) disabledRedirect() gin.HandlerFunc {
return func(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")

View File

@@ -12,13 +12,8 @@
<link rel="stylesheet" href="{{.BasePath}}/css/layout.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/primitives.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/modals.css?v={{.Version}}">
{{/* chat.css removed in v0.37.10 — replaced by sw-chat-surface.css + sw-chat-pane.css */}}
{{/* panels.css, pane-container.css, chat-pane.css removed in v0.37.10 */}}
<link rel="stylesheet" href="{{.BasePath}}/css/surfaces.css?v={{.Version}}">
{{/* splash.css removed in v0.37.12 — login is Preact with sw-login.css */}}
<link rel="stylesheet" href="{{.BasePath}}/css/sw-primitives.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-chat-pane.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-notes-pane.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/user-menu.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}">
@@ -94,6 +89,7 @@
{{if eq .Surface "admin"}}{{template "surface-admin" .}}
{{else if eq .Surface "team-admin"}}{{template "surface-team-admin" .}}
{{else if eq .Surface "settings"}}{{template "surface-settings" .}}
{{else if eq .Surface "welcome"}}{{template "surface-welcome" .}}
{{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}}
{{else}}<div style="padding:20px">Unknown surface: {{.Surface}}</div>
{{end}}
@@ -128,6 +124,7 @@
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
{{if eq .Surface "welcome"}}{{template "scripts-welcome" .}}{{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

@@ -1,38 +0,0 @@
{{/*
Chat Pane Component - reusable chat scaffold.
Usage: {{template "chat-pane" dict "ID" "main"}}
Creates mount points: {ID}ChatMessages, {ID}ChatInput, {ID}SendBtn, {ID}ModelSel
ChatPane.create() in chat-pane.js binds to these IDs.
The header bar ({ID}ChatHeader) is hidden by default.
Standalone panes (editor assist) show it for chat switching + model selection.
*/}}
{{define "chat-pane"}}
<div class="chat-pane" id="{{.ID}}ChatPane">
<div class="chat-pane-header" id="{{.ID}}ChatHeader" style="display:none;">
<div class="chat-pane-header-left">
<select class="chat-pane-chat-select" id="{{.ID}}ChatSelect" title="Switch chat">
<option value="">New conversation</option>
</select>
<button class="chat-pane-new-btn" id="{{.ID}}ChatNewBtn" title="New chat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="chat-pane-header-right">
<div class="chat-pane-model-sel" id="{{.ID}}ModelSel"></div>
</div>
</div>
<div class="chat-pane-messages" id="{{.ID}}ChatMessages"></div>
<div class="chat-pane-input-bar">
<div class="chat-pane-input-wrap">
<div class="chat-pane-input" id="{{.ID}}ChatInput"></div>
<button class="chat-pane-send" id="{{.ID}}SendBtn" title="Send">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
<div class="chat-pane-toolbar" id="{{.ID}}Toolbar"></div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,25 @@
{{/*
Welcome surface — shown when no extension surfaces are installed.
Renders topbar + welcome card with getting-started info.
*/}}
{{define "surface-welcome"}}
<div id="welcome-mount" style="display:flex;flex-direction:column;height:100%;"></div>
{{end}}
{{define "scripts-welcome"}}
<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/welcome/index.js?v={{.Version}}');
</script>
{{end}}

View File

@@ -9,7 +9,6 @@
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/primitives.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/chat.css?v={{.Version}}">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font); background: var(--bg); color: var(--text); }

View File

@@ -24,7 +24,7 @@ func TestPVC_PutGetRoundTrip(t *testing.T) {
ctx := context.Background()
data := []byte("hello, storage world")
key := "files/channel-1/att-1_test.txt"
key := "files/test-1/att-1_test.txt"
// Put
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
@@ -129,18 +129,18 @@ func TestPVC_DeletePrefix(t *testing.T) {
ctx := context.Background()
// Create several files under a channel prefix
prefix := "files/channel-abc/"
prefix := "files/test-abc/"
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
key := prefix + name
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
}
// Also create a file in a different channel
other := "files/channel-other/keep.txt"
other := "files/test-other/keep.txt"
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
// Delete the channel prefix
err := s.DeletePrefix(ctx, "files/channel-abc")
err := s.DeletePrefix(ctx, "files/test-abc")
if err != nil {
t.Fatalf("DeletePrefix: %v", err)
}

View File

@@ -78,7 +78,7 @@ func TestS3_KeyValidation(t *testing.T) {
good := []string{
"files/ch/f.txt",
"files/channel-1/att-abc_test.pdf",
"files/test-1/att-abc_test.pdf",
"processing/abc123/status.json",
}
for _, key := range good {
@@ -145,7 +145,7 @@ func TestS3_Integration_PutGetRoundTrip(t *testing.T) {
ctx := context.Background()
data := []byte("hello, S3 storage world")
key := "files/channel-1/att-1_test.txt"
key := "files/test-1/att-1_test.txt"
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
if err != nil {
@@ -214,16 +214,16 @@ func TestS3_Integration_DeletePrefix(t *testing.T) {
s := testS3Store(t)
ctx := context.Background()
prefix := "files/channel-abc/"
prefix := "files/test-abc/"
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
key := prefix + name
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
}
other := "files/channel-other/keep.txt"
other := "files/test-other/keep.txt"
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
err := s.DeletePrefix(ctx, "files/channel-abc")
err := s.DeletePrefix(ctx, "files/test-abc")
if err != nil {
t.Fatalf("DeletePrefix: %v", err)
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"switchboard-core/models"
)
@@ -224,8 +223,6 @@ func (s *TeamStore) IsMember(ctx context.Context, teamID, userID string) (bool,
return exists, err
}
// unused but keeping for reference
var _ = fmt.Sprintf
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────

View File

@@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"switchboard-core/models"
@@ -231,8 +230,6 @@ func (s *TeamStore) IsMember(ctx context.Context, teamID, userID string) (bool,
return exists, err
}
// unused but keeping for reference
var _ = fmt.Sprintf
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────