diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f6a0f80..d4ead2d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -178,6 +178,38 @@ def integrate redirect_to root_path, notice: "Day completed." end + # POST /save_textarea + def save_textarea + return render json: { error: "Not authenticated" }, status: 401 unless current_resonance + return render json: { error: "Active subscription required" }, status: 403 unless current_resonance.active_subscription? + + # Check for cross-device continuity divergence + client_universe_time = params[:universe_time] + server_universe_time = current_resonance.universe_time + + if client_universe_time && client_universe_time != server_universe_time + # Parse and compare "day:count" format + client_day, client_count = client_universe_time.split(":").map(&:to_i) + server_day, server_count = server_universe_time.split(":").map(&:to_i) + + # If client is behind server, they're working with stale state + if client_day < server_day || (client_day == server_day && client_count < server_count) + render json: { + error: "continuity_divergence", + message: "This space moved forward elsewhere.", + server_universe_time: server_universe_time + }, status: 409 + return + end + end + + # Save textarea content + current_resonance.textarea = params[:textarea] + current_resonance.save! + + render json: { status: "saved", universe_time: current_resonance.universe_time } + end + # POST /subscription def create_subscription return redirect_to root_path, alert: "Please sign in" unless current_resonance diff --git a/app/javascript/controllers/chat_controller.js b/app/javascript/controllers/chat_controller.js index 9981427..6fa7a10 100644 --- a/app/javascript/controllers/chat_controller.js +++ b/app/javascript/controllers/chat_controller.js @@ -4,12 +4,14 @@ export default class extends Controller { static targets = ["log", "input"] static values = { narrative: Array, - universeTime: String + universeTime: String, + savedTextarea: String } connect() { this.loadExistingMessages() this.loadSavedInput() + this.saveDebounceTimeout = null } loadExistingMessages() { @@ -40,8 +42,13 @@ export default class extends Controller { textarea.style.height = "auto" textarea.style.height = textarea.scrollHeight + "px" - // Save input to localStorage + // Save input to localStorage immediately this.saveInputToStorage(textarea.value) + + // Debounced save to server (unless this is a programmatic event from loadSavedInput) + if (!event.skipServerSave) { + this.debouncedSaveToServer(textarea.value) + } } saveInputToStorage(value) { @@ -49,19 +56,73 @@ export default class extends Controller { localStorage.setItem(storageKey, value) } + debouncedSaveToServer(value) { + // Clear existing timeout + if (this.saveDebounceTimeout) { + clearTimeout(this.saveDebounceTimeout) + } + + // Set new timeout to save after 1.5 seconds of inactivity + this.saveDebounceTimeout = setTimeout(() => { + this.saveToServer(value) + }, 1500) + } + + async saveToServer(value) { + try { + const response = await fetch("/textarea", { + method: "PUT", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content + }, + body: JSON.stringify({ + textarea: value, + universe_time: this.universeTimeValue + }) + }) + + if (response.status === 409) { + // Continuity divergence - this space moved forward elsewhere + const data = await response.json() + console.warn("Textarea save blocked: continuity divergence", data) + // Could show a subtle notice here if desired + } else if (!response.ok) { + console.error("Failed to save textarea:", response.status) + } + } catch (error) { + console.error("Error saving textarea:", error) + // Fail silently - localStorage still has it + } + } + loadSavedInput() { + // Prefer server-saved value over localStorage + const serverSaved = this.savedTextareaValue const storageKey = `yours-input-${this.universeTimeValue || 'current'}` - const savedInput = localStorage.getItem(storageKey) + const localSaved = localStorage.getItem(storageKey) + + // Use whichever is longer (assumes the longer one is more recent) + // In practice, server value is canonical for cross-device sync + const savedInput = (serverSaved && serverSaved.length >= (localSaved || "").length) + ? serverSaved + : localSaved + if (savedInput) { this.inputTarget.value = savedInput - // Trigger input event to auto-expand - this.inputTarget.dispatchEvent(new Event('input')) + // Trigger input event to auto-expand (but don't re-trigger server save) + const event = new Event('input') + event.skipServerSave = true + this.inputTarget.dispatchEvent(event) } } clearSavedInput() { const storageKey = `yours-input-${this.universeTimeValue || 'current'}` localStorage.removeItem(storageKey) + + // Also clear on server + this.saveToServer("") } send() { diff --git a/app/models/resonance.rb b/app/models/resonance.rb index b4b441c..e58f5c7 100644 --- a/app/models/resonance.rb +++ b/app/models/resonance.rb @@ -111,6 +111,15 @@ def universe_day=(value) self.encrypted_universe_day = value.nil? ? nil : encrypt_field(value.to_s) end + # Textarea contents + def textarea + decrypt_field(encrypted_textarea) + end + + def textarea=(value) + self.encrypted_textarea = encrypt_field(value) + end + # Universe time as "day:message_count" (e.g., "3:14" for day 3, 14 messages) # This serves as a monotonically increasing guard against cross-device state clobbering def universe_time diff --git a/app/views/application/account.html.erb b/app/views/application/account.html.erb index 416b328..dda8813 100644 --- a/app/views/application/account.html.erb +++ b/app/views/application/account.html.erb @@ -61,7 +61,7 @@

Begin again

-

Want to clear in-universe memory completely? (This won't affect your subscription or your in-universe day counter.)

+

Want to clear in-universe memory completely? (This will not reset the day counter, and will not cancel your subscription.)

This action cannot be undone.

<%= button_to "Begin Again", diff --git a/app/views/application/chat.html.erb b/app/views/application/chat.html.erb index ec3a575..f3afa7b 100644 --- a/app/views/application/chat.html.erb +++ b/app/views/application/chat.html.erb @@ -1,4 +1,4 @@ -
+
" style="display: flex; flex-direction: column; gap: 1rem;">
diff --git a/config/routes.rb b/config/routes.rb index 0d44b87..7e82ad2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,7 @@ # Service routes post "stream", to: "application#stream" post "integrate", to: "application#integrate" + put "textarea", to: "application#save_textarea" post "subscription", to: "application#create_subscription" delete "subscription", to: "application#destroy_subscription" post "reset", to: "application#reset" diff --git a/db/migrate/20251010233556_add_encrypted_textarea_to_resonances.rb b/db/migrate/20251010233556_add_encrypted_textarea_to_resonances.rb new file mode 100644 index 0000000..3d78d37 --- /dev/null +++ b/db/migrate/20251010233556_add_encrypted_textarea_to_resonances.rb @@ -0,0 +1,5 @@ +class AddEncryptedTextareaToResonances < ActiveRecord::Migration[8.0] + def change + add_column :resonances, :encrypted_textarea, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index d340d14..9123039 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_10_03_235135) do +ActiveRecord::Schema[8.0].define(version: 2025_10_10_233556) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -19,5 +19,6 @@ t.text "encrypted_integration_harmonic_by_night" t.text "encrypted_narrative_accumulation_by_day" t.text "encrypted_universe_day" + t.text "encrypted_textarea" end end diff --git a/spec/models/resonance_spec.rb b/spec/models/resonance_spec.rb index e56c423..0696a3c 100644 --- a/spec/models/resonance_spec.rb +++ b/spec/models/resonance_spec.rb @@ -240,4 +240,64 @@ expect(resonance).to be_valid end end + + describe "#textarea" do + let(:google_id) { "google-user-123" } + + it "stores and retrieves textarea content" do + resonance = Resonance.find_or_create_by_google_id(google_id) + resonance.textarea = "thinking about something..." + resonance.save! + + reloaded = Resonance.find_by_google_id(google_id) + expect(reloaded.textarea).to eq("thinking about something...") + end + + it "encrypts the textarea content" do + resonance = Resonance.find_or_create_by_google_id(google_id) + resonance.textarea = "secret thought" + resonance.save! + + expect(resonance.encrypted_textarea).to be_present + expect(resonance.encrypted_textarea).not_to include("secret thought") + end + + it "persists across day transitions" do + resonance = Resonance.find_or_create_by_google_id(google_id) + resonance.textarea = "carrying this forward" + resonance.universe_day = 1 + resonance.narrative_accumulation_by_day = [ + { role: "user", content: [ { type: "text", text: "Day 1 message" } ] } + ] + resonance.save! + + # Simulate day transition (like integrate action does) + resonance.universe_day = 2 + resonance.narrative_accumulation_by_day = [] + resonance.integration_harmonic_by_night = "some harmonic" + resonance.save! + + # Textarea should persist unchanged + reloaded = Resonance.find_by_google_id(google_id) + expect(reloaded.textarea).to eq("carrying this forward") + expect(reloaded.universe_day).to eq(2) + end + + it "returns nil when not set" do + resonance = Resonance.find_or_create_by_google_id(google_id) + expect(resonance.textarea).to be_nil + end + + it "can be explicitly cleared" do + resonance = Resonance.find_or_create_by_google_id(google_id) + resonance.textarea = "something" + resonance.save! + + resonance.textarea = nil + resonance.save! + + reloaded = Resonance.find_by_google_id(google_id) + expect(reloaded.textarea).to be_nil + end + end end