sova-devtools 0.1.10

In-app DevTools bar for Sova (HTML inject, SSE timeline, request snapshots)
Documentation
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { Plus, RefreshCw, Trash2 } from "@lucide/vue";
import { postAction } from "../api";
import { useDevToolsStore } from "../stores/devtools";

const store = useDevToolsStore();

const hasSession = computed(() => {
  const cfg = store.config as { features?: string[] } | null;
  return cfg?.features?.includes("console-session") ?? false;
});

const loading = ref(false);
const error = ref<string | null>(null);
const sessionId = ref("");
const userId = ref("");
const rows = ref<{ key: string; value: string }[]>([]);
const newKey = ref("");
const newValue = ref("");

type SessionSnap = {
  session_id?: string;
  user_id?: string | null;
  keys?: Record<string, string>;
};

function applySnap(data: SessionSnap) {
  sessionId.value = data.session_id ?? "";
  userId.value = data.user_id ?? "";
  const keys = data.keys ?? {};
  rows.value = Object.entries(keys).map(([key, value]) => ({ key, value }));
}

async function run(op: string, extra: Record<string, unknown> = {}) {
  loading.value = true;
  error.value = null;
  try {
    const res = await postAction(store.api, "session", { op, ...extra });
    if (!res.ok) {
      error.value = res.error ?? "session action failed";
      return;
    }
    if (op === "destroy") {
      sessionId.value = "";
      userId.value = "";
      rows.value = [];
      return;
    }
    applySnap((res.result ?? {}) as SessionSnap);
  } catch (e) {
    error.value = e instanceof Error ? e.message : String(e);
  } finally {
    loading.value = false;
  }
}

async function refresh() {
  await run("list");
}

async function saveRow(row: { key: string; value: string }) {
  if (!row.key.trim()) return;
  await run("set", { key: row.key.trim(), value: row.value });
}

async function addKey() {
  if (!newKey.value.trim()) return;
  await run("set", { key: newKey.value.trim(), value: newValue.value });
  newKey.value = "";
  newValue.value = "";
}

async function deleteRow(key: string) {
  await run("del", { key });
}

async function bindUser() {
  if (!userId.value.trim()) return;
  await run("bind_user", { user_id: userId.value.trim() });
}

onMounted(() => {
  if (hasSession.value) void refresh();
});
</script>

<template>
  <div
    v-if="hasSession"
    class="flex flex-col gap-3 rounded-[var(--dt-radius)] border border-[var(--dt-border)] bg-[var(--dt-surface)] p-3"
  >
    <div class="flex flex-wrap items-center gap-2">
      <span class="text-[10px] uppercase tracking-wide text-[var(--dt-faint)]"
        >Live session</span
      >
      <span
        v-if="sessionId"
        class="dt-mono truncate text-[10px] text-[var(--dt-muted)]"
        :title="sessionId"
        >{{ sessionId }}</span
      >
      <button
        type="button"
        class="ml-auto inline-flex items-center gap-1 rounded border border-[var(--dt-border)] px-2 py-1 text-[10px] text-[var(--dt-accent)] disabled:opacity-50"
        :disabled="loading"
        @click="refresh"
      >
        <RefreshCw class="size-3" /> Refresh
      </button>
    </div>

    <div class="flex flex-wrap items-end gap-2">
      <label class="flex min-w-[8rem] flex-1 flex-col gap-1 text-[10px] text-[var(--dt-faint)]">
        user_id
        <input
          v-model="userId"
          type="text"
          placeholder="user id"
          class="rounded border border-[var(--dt-border)] bg-[var(--dt-bg)] px-2 py-1.5 font-mono text-[11px]"
        />
      </label>
      <button
        type="button"
        class="rounded border border-[var(--dt-border)] px-2 py-1.5 text-[10px] text-[var(--dt-accent)] disabled:opacity-50"
        :disabled="loading || !userId.trim()"
        @click="bindUser"
      >
        Bind user
      </button>
      <button
        type="button"
        class="rounded border border-[var(--dt-border)] px-2 py-1.5 text-[10px] text-[var(--dt-warn)] disabled:opacity-50"
        :disabled="loading"
        @click="run('regenerate')"
      >
        Regenerate
      </button>
      <button
        type="button"
        class="rounded border border-[var(--dt-border)] px-2 py-1.5 text-[10px] text-[var(--dt-err)] disabled:opacity-50"
        :disabled="loading"
        @click="run('clear')"
      >
        Clear
      </button>
    </div>

    <div class="flex flex-col gap-2">
      <div
        v-for="(row, i) in rows"
        :key="`${row.key}-${i}`"
        class="flex items-center gap-2"
      >
        <input
          v-model="row.key"
          type="text"
          readonly
          class="w-28 rounded border border-[var(--dt-border)] bg-[var(--dt-bg)] px-2 py-1 font-mono text-[11px] text-[var(--dt-muted)]"
        />
        <input
          v-model="row.value"
          type="text"
          class="min-w-0 flex-1 rounded border border-[var(--dt-border)] bg-[var(--dt-bg)] px-2 py-1 font-mono text-[11px]"
          @keydown.enter="saveRow(row)"
        />
        <button
          type="button"
          class="rounded border border-[var(--dt-border)] px-2 py-1 text-[10px] text-[var(--dt-accent)] disabled:opacity-50"
          :disabled="loading"
          @click="saveRow(row)"
        >
          Save
        </button>
        <button
          type="button"
          class="rounded border border-[var(--dt-border)] p-1 text-[var(--dt-err)] disabled:opacity-50"
          :disabled="loading"
          title="Delete key"
          @click="deleteRow(row.key)"
        >
          <Trash2 class="size-3" />
        </button>
      </div>

      <div class="flex items-center gap-2 border-t border-[var(--dt-border)] pt-2">
        <input
          v-model="newKey"
          type="text"
          placeholder="key"
          class="w-28 rounded border border-[var(--dt-border)] bg-[var(--dt-bg)] px-2 py-1 font-mono text-[11px]"
        />
        <input
          v-model="newValue"
          type="text"
          placeholder="value"
          class="min-w-0 flex-1 rounded border border-[var(--dt-border)] bg-[var(--dt-bg)] px-2 py-1 font-mono text-[11px]"
          @keydown.enter="addKey"
        />
        <button
          type="button"
          class="inline-flex items-center gap-1 rounded bg-[var(--dt-accent)] px-3 py-1 text-[10px] font-semibold text-white disabled:opacity-50"
          :disabled="loading || !newKey.trim()"
          @click="addKey"
        >
          <Plus class="size-3" /> Add
        </button>
      </div>
    </div>

    <p v-if="error" class="text-[11px] text-[var(--dt-err)]">{{ error }}</p>
    <p class="text-[10px] text-[var(--dt-faint)]">
      Edits apply to your browser session (cookie), not the selected snapshot.
    </p>
  </div>
</template>