Add version footer, task history UI, and note version retention policy
- App.vue: quiet version footer (fetches /api/version, 0.45 opacity) - TaskEditorView: History button + HistoryPanel integration (mirrors NoteEditorView) - note_versions.py: raise MAX_VERSIONS 20→50; add 5-min minimum interval gate to prevent autosave exhausting history slots Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+22
-1
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, watch } from "vue";
|
import { onMounted, onUnmounted, ref, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import AppHeader from "@/components/AppHeader.vue";
|
import AppHeader from "@/components/AppHeader.vue";
|
||||||
import ToastNotification from "@/components/ToastNotification.vue";
|
import ToastNotification from "@/components/ToastNotification.vue";
|
||||||
@@ -8,10 +8,12 @@ import { useShortcuts } from "@/composables/useShortcuts";
|
|||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
import { apiGet } from "@/api/client";
|
||||||
|
|
||||||
useTheme();
|
useTheme();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const appVersion = ref("dev");
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
@@ -123,6 +125,12 @@ onMounted(async () => {
|
|||||||
if (authStore.isAuthenticated) {
|
if (authStore.isAuthenticated) {
|
||||||
startAppServices();
|
startAppServices();
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const data = await apiGet<{ version: string }>("/api/version");
|
||||||
|
appVersion.value = data.version;
|
||||||
|
} catch {
|
||||||
|
// silent — version display is non-critical
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -150,6 +158,7 @@ onUnmounted(() => {
|
|||||||
<div id="main-content" class="app-content">
|
<div id="main-content" class="app-content">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
|
<footer class="app-footer">v{{ appVersion }}</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Keyboard shortcuts overlay -->
|
<!-- Keyboard shortcuts overlay -->
|
||||||
@@ -299,6 +308,18 @@ onUnmounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Version footer */
|
||||||
|
.app-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.45;
|
||||||
|
user-select: none;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Shortcuts overlay */
|
/* Shortcuts overlay */
|
||||||
.shortcuts-overlay {
|
.shortcuts-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
|||||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||||
import InlineAssistPanel from "@/components/InlineAssistPanel.vue";
|
import InlineAssistPanel from "@/components/InlineAssistPanel.vue";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||||
|
import HistoryPanel from "@/components/HistoryPanel.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -321,6 +322,7 @@ async function save() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showDeleteConfirm = ref(false);
|
const showDeleteConfirm = ref(false);
|
||||||
|
const showHistory = ref(false);
|
||||||
|
|
||||||
function remove() {
|
function remove() {
|
||||||
if (taskId.value) {
|
if (taskId.value) {
|
||||||
@@ -386,6 +388,7 @@ useEditorGuards(dirty, save);
|
|||||||
<button class="btn-save" @click="save" :disabled="saving">
|
<button class="btn-save" @click="save" :disabled="saving">
|
||||||
{{ saving ? "Saving..." : "Save" }}
|
{{ saving ? "Saving..." : "Save" }}
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="isEditing" class="btn-history" @click="showHistory = true">History</button>
|
||||||
<button v-if="isEditing" class="btn-delete" @click="remove">
|
<button v-if="isEditing" class="btn-delete" @click="remove">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -654,6 +657,15 @@ useEditorGuards(dirty, save);
|
|||||||
@confirm="confirmDelete"
|
@confirm="confirmDelete"
|
||||||
@cancel="showDeleteConfirm = false"
|
@cancel="showDeleteConfirm = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- History panel -->
|
||||||
|
<HistoryPanel
|
||||||
|
v-if="showHistory && taskId"
|
||||||
|
:note-id="taskId"
|
||||||
|
:current-body="body"
|
||||||
|
@restore="(b: string, t: string[]) => { body = b; tags = t; markDirty(); }"
|
||||||
|
@close="showHistory = false"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -843,6 +855,22 @@ useEditorGuards(dirty, save);
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* History button */
|
||||||
|
.btn-history {
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn-history:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Tag suggest row inside sidebar */
|
/* Tag suggest row inside sidebar */
|
||||||
.tag-suggest-row {
|
.tag-suggest-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,15 +1,39 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from fabledassistant.models import async_session
|
from fabledassistant.models import async_session
|
||||||
from fabledassistant.models.note_version import NoteVersion
|
from fabledassistant.models.note_version import NoteVersion
|
||||||
|
|
||||||
MAX_VERSIONS = 20
|
# Maximum snapshots retained per note.
|
||||||
|
MAX_VERSIONS = 50
|
||||||
|
|
||||||
|
# Minimum seconds between snapshots. Prevents autosave from consuming all
|
||||||
|
# slots — with autosave at 60 s intervals, a 5-minute gate means at most
|
||||||
|
# one snapshot per editing session segment rather than one per save tick.
|
||||||
|
MIN_VERSION_INTERVAL_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
async def create_version(
|
async def create_version(
|
||||||
user_id: int, note_id: int, body: str, title: str, tags: list[str] | None = None
|
user_id: int, note_id: int, body: str, title: str, tags: list[str] | None = None
|
||||||
) -> NoteVersion:
|
) -> NoteVersion | None:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
|
# Skip if a recent snapshot already exists within the minimum interval.
|
||||||
|
recent = await session.execute(
|
||||||
|
select(NoteVersion.created_at)
|
||||||
|
.where(NoteVersion.note_id == note_id, NoteVersion.user_id == user_id)
|
||||||
|
.order_by(NoteVersion.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
last_at = recent.scalar_one_or_none()
|
||||||
|
if last_at is not None:
|
||||||
|
# created_at is stored with timezone; ensure comparison is tz-aware
|
||||||
|
if last_at.tzinfo is None:
|
||||||
|
last_at = last_at.replace(tzinfo=timezone.utc)
|
||||||
|
age = (datetime.now(timezone.utc) - last_at).total_seconds()
|
||||||
|
if age < MIN_VERSION_INTERVAL_SECONDS:
|
||||||
|
return None
|
||||||
|
|
||||||
version = NoteVersion(note_id=note_id, user_id=user_id, body=body, title=title, tags=tags or [])
|
version = NoteVersion(note_id=note_id, user_id=user_id, body=body, title=title, tags=tags or [])
|
||||||
session.add(version)
|
session.add(version)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user