fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
FlowDeck CI / test (push) Failing after 3h13m58s
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / docker (push) Skipped

- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient
  loovverture et le redimensionnement inoperants -> cblage direct sur le document.
- Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw,
  clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks.
- database-table-container margin:0 (tableau colle a gauche, marge Library).
- .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px.
- ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
This commit is contained in:
2026-10-05 22:47:36 -04:00
parent 894004a1f5
commit 1d1cdbd618
63 changed files with 9473 additions and 393 deletions
+324 -7
View File
@@ -13,6 +13,11 @@
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
<!-- My Tasks + page « /db » : lies dans le SHELL et non dans la zone
swappée. Un <link> à l'intérieur de `.main-wrapper` peut être retiré par
htmx lors d'une navigation partielle : la page revenait alors sans
aucune feuille de style (composants blancs en thème sombre). -->
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@@ -241,24 +246,38 @@
</div>
</div>
<!-- Navigation icons row -->
<div class="sidebar-nav-row">
<a href="{% if local_workspaces and local_workspaces|length > 0 %}/local-workspace?ws={{ local_workspaces[0].id }}{% else %}/workspaces{% endif %}" class="nav-icon-btn home-btn" :class="{ active: currentView === 'home' }" title="Home">
<!-- Navigation tabs row — collapsible pills: the active tab expands with
its label, the others collapse to their icon. Selecting a tab replaces
the whole sidebar content (Home tree / Chat history / Meetings / Inbox). -->
<div class="sidebar-nav-row" role="tablist" aria-label="Sidebar sections">
<a href="{% if local_ws_id %}/local-workspace?ws={{ local_ws_id }}{% elif local_workspaces and local_workspaces|length > 0 %}/local-workspace?ws={{ local_workspaces[0].id }}{% else %}/workspaces{% endif %}" class="nav-icon-btn home-btn nav-tab-btn" data-tab="home" :class="{ active: sidebarTab === 'home' }" @click="setSidebarTab('home')" :aria-selected="sidebarTab === 'home'" role="tab" title="Home">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
Home
<span class="nav-tab-label" :style="sidebarTab === 'home' ? 'display:inline-block' : 'display:none'">Home</span>
</a>
<button class="nav-icon-btn" title="Chat (Ctrl+J)" onclick="window.fdAgent&&window.fdAgent.open()">
<button type="button" class="nav-icon-btn nav-tab-btn" data-tab="chat" :class="{ active: sidebarTab === 'chat' }" @click="setSidebarTab('chat')" :aria-selected="sidebarTab === 'chat'" role="tab" title="Chat (Ctrl+J)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
<span class="nav-tab-label" :style="sidebarTab === 'chat' ? 'display:inline-block' : 'display:none'">Chat</span>
<span class="nav-tab-badge" x-show="chatHistory.length" x-cloak x-text="chatHistory.length" :style="sidebarTab === 'chat' ? 'display:none' : ''"></span>
</button>
<button class="nav-icon-btn" title="Inbox">
<button type="button" class="nav-icon-btn nav-tab-btn" data-tab="meetings" :class="{ active: sidebarTab === 'meetings' }" @click="setSidebarTab('meetings')" :aria-selected="sidebarTab === 'meetings'" role="tab" title="Meetings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span class="nav-tab-label" :style="sidebarTab === 'meetings' ? 'display:inline-block' : 'display:none'">Meeting</span>
<span class="nav-tab-badge" x-show="meetingToday.length" x-cloak x-text="meetingToday.length" :style="sidebarTab === 'meetings' ? 'display:none' : ''"></span>
</button>
<button type="button" class="nav-icon-btn nav-tab-btn" data-tab="inbox" :class="{ active: sidebarTab === 'inbox' }" @click="setSidebarTab('inbox')" :aria-selected="sidebarTab === 'inbox'" role="tab" title="Inbox">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/></svg>
<span class="nav-tab-label" :style="sidebarTab === 'inbox' ? 'display:inline-block' : 'display:none'">Inbox</span>
<span class="nav-tab-badge nav-tab-badge-accent" x-show="inboxUnread > 0" x-cloak x-text="inboxUnread > 99 ? '99+' : inboxUnread" :style="sidebarTab === 'inbox' ? 'display:none' : ''"></span>
</button>
<button class="nav-icon-btn" @click="openQuickFind" title="Search" style="margin-left:auto;">
<button type="button" class="nav-icon-btn" @click="openQuickFind" title="Search" style="margin-left:auto;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
</div>
<div class="sidebar-scroll">
<!-- ── TAB: Home (workspace tree, recents, favorites, libraries…) ── -->
<div class="sidebar-tab-panel" x-show="sidebarTab === 'home'" x-cloak>
<!-- Workspace / Private -->
<div class="sidebar-section" x-show="isSectionVisible('workspace')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('workspace')" @contextmenu.prevent="openSectionMenu($event, 'workspace')">
@@ -571,6 +590,123 @@
</a>
</div>
</div><!-- /sidebar-tab-panel home -->
<!-- ── TAB: Chat (agent shortcuts + conversation history) ── -->
<div class="sidebar-tab-panel" x-show="sidebarTab === 'chat'" x-cloak>
<div class="sb-panel-header">
<span class="sb-panel-title">Chat</span>
<button type="button" class="sb-panel-action" @click="chatNewConversation()" title="New conversation">{{ fd_icon("plus",14) }}</button>
</div>
<!-- Circular agent shortcuts -->
<div class="sb-agent-row" x-show="agentList.length">
<template x-for="a in agentList.slice(0, 8)" :key="a.id">
<button type="button" class="sb-agent-avatar" :title="a.name" @click="agentOpen(a.id)">
<span x-text="a.icon || '\u{1F916}'"></span>
</button>
</template>
</div>
<div class="sidebar-subgroup">
<div class="sidebar-subgroup-label">Past 30 days</div>
<ul class="sidebar-items">
<template x-for="c in chatRecent" :key="c.id">
<li class="sidebar-item" @click="chatOpenConversation(c.id)">
<span class="page-icon page-icon-svg">{{ fd_icon("message-square",14) }}</span>
<span class="page-name" x-text="c.title || 'New conversation'"></span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!chatLoading && !chatRecent.length">
<span class="page-name text-dim">No recent conversations</span>
</li>
</ul>
</div>
<div class="sidebar-subgroup" x-show="chatOlder.length">
<div class="sidebar-subgroup-label">Older</div>
<ul class="sidebar-items">
<template x-for="c in chatOlder" :key="c.id">
<li class="sidebar-item" @click="chatOpenConversation(c.id)">
<span class="page-icon page-icon-svg">{{ fd_icon("message-square",14) }}</span>
<span class="page-name" x-text="c.title || 'New conversation'"></span>
</li>
</template>
</ul>
</div>
<div class="sidebar-item empty-hint" x-show="chatLoading" style="padding:8px;">
<span class="page-name text-dim">Loading…</span>
</div>
</div>
<!-- ── TAB: Meeting (upcoming events + quick actions) ── -->
<div class="sidebar-tab-panel" x-show="sidebarTab === 'meetings'" x-cloak>
<div class="sb-panel-header">
<span class="sb-panel-title">Meeting</span>
<button type="button" class="sb-panel-action" @click="loadMeetings(true)" title="Refresh">{{ fd_icon("refresh",14) }}</button>
</div>
<div class="sb-filter-row">
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'upcoming' }" @click="meetingFilter = 'upcoming'">Upcoming</button>
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'today' }" @click="meetingFilter = 'today'">Today</button>
</div>
<div class="sb-quick-actions">
<button type="button" class="sb-quick-btn" @click="meetingTranscribe()">{{ fd_icon("upload",14) }} Transcribe a podcast</button>
<button type="button" class="sb-quick-btn" @click="meetingNote()">{{ fd_icon("sparkles",14) }} New AI meeting note</button>
<input type="file" x-ref="meetingAudio" accept="audio/*" class="sb-hidden-input" @change="meetingUpload($event)">
</div>
<ul class="sidebar-items">
<template x-for="ev in meetingVisible" :key="ev.collection_id + '-' + ev.id">
<li class="sidebar-item" @click="meetingOpen(ev)">
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
<span class="page-name" x-text="ev.title"></span>
<span class="sb-item-meta" x-text="ev.today ? 'Today' : ev.date"></span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!meetingsLoading && !meetingVisible.length">
<span class="page-name text-dim" x-text="meetingFilter === 'today' ? 'Nothing scheduled today' : 'No upcoming meetings'"></span>
</li>
<li class="sidebar-item empty-hint" x-show="meetingsLoading">
<span class="page-name text-dim">Loading…</span>
</li>
</ul>
</div>
<!-- ── TAB: Inbox (notifications) ── -->
<div class="sidebar-tab-panel" x-show="sidebarTab === 'inbox'" x-cloak>
<div class="sb-panel-header">
<span class="sb-panel-title">Inbox <span class="sb-panel-count" x-show="inboxUnread > 0" x-text="inboxUnread"></span></span>
<div style="display:flex;gap:4px;">
<button type="button" class="sb-panel-action" x-show="inboxUnread > 0" @click="inboxMarkAllRead()" title="Mark all read">{{ fd_icon("check-square",14) }}</button>
<button type="button" class="sb-panel-action" @click="inboxFilter = (inboxFilter === 'all' ? 'unread' : 'all')"
:title="inboxFilter === 'all' ? 'Show unread only' : 'Show all'" x-text="inboxFilter === 'all' ? 'All' : 'Unread'"></button>
<button type="button" class="sb-panel-action" @click="loadInbox()" title="Refresh">{{ fd_icon("refresh",14) }}</button>
</div>
</div>
<ul class="sidebar-items">
<template x-for="n in inboxVisible" :key="n.id">
<li class="sidebar-item" :class="{ 'sb-item-unread': !n.is_read }" @click="inboxOpen(n)">
<span class="page-icon page-icon-svg">{{ fd_icon("bell",14) }}</span>
<span style="flex:1;min-width:0;">
<span class="page-name" style="display:block;" x-text="n.title"></span>
<span class="sb-item-sub" x-text="n.message"></span>
</span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!inboxLoading && !inboxVisible.length" style="flex-direction:column;align-items:center;gap:4px;padding:22px 8px;">
<span style="font-size:13px;font-weight:600;color:var(--text-secondary);">Inbox Zero</span>
<span class="page-name text-dim">You&rsquo;re all caught up</span>
</li>
<li class="sidebar-item empty-hint" x-show="inboxLoading">
<span class="page-name text-dim">Loading…</span>
</li>
</ul>
</div>
<!-- Footer -->
<div class="sidebar-footer">
<div style="display:flex; gap:6px;">
@@ -1128,6 +1264,15 @@
Alpine.data('appState', appState);
});
/** Parse a SQL/ISO timestamp ("2026-10-04 12:00:00") into a ms epoch.
Shared by the sidebar Chat tab (grouping by recency). */
window.fdTime = function(value) {
if (!value) return 0;
var t = new Date(String(value).replace(' ', 'T'));
if (isNaN(t.getTime())) t = new Date(value);
return isNaN(t.getTime()) ? 0 : t.getTime();
};
function appState() {
return {
init() {
@@ -1135,6 +1280,8 @@
this.loadSidebarConfig();
this.loadAgents();
this.loadTeamspaces();
// Badge counts on the collapsed tabs (lazy fetch, no blocking).
this.loadInbox();
this.initSidebarWidth();
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
this.startClipAutoRefresh();
@@ -1240,6 +1387,176 @@
giteaRepo: '{{ gitea_repo|default("") }}',
giteaTree: [], // loaded client-side for sidebar
currentView: 'home',
// ── Collapsible sidebar tabs (Home / Chat / Meeting / Inbox) ──
// The active tab renders as a pill with its label, the others collapse
// to an icon; selecting one replaces the whole sidebar content.
sidebarTab: (function() {
try {
var saved = localStorage.getItem('fd_sidebar_tab');
return ['home', 'chat', 'meetings', 'inbox'].indexOf(saved) >= 0 ? saved : 'home';
} catch (e) { return 'home'; }
})(),
setSidebarTab(tab) {
this.sidebarTab = tab;
this.currentView = tab;
try { localStorage.setItem('fd_sidebar_tab', tab); } catch (e) {}
if (tab === 'chat') this.loadChatHistory();
else if (tab === 'meetings') this.loadMeetings();
else if (tab === 'inbox') this.loadInbox();
},
// ── Chat tab ──
chatHistory: [],
chatLoading: false,
get chatRecent() {
var cut = Date.now() - 30 * 86400000;
return this.chatHistory.filter(function(c) { return fdTime(c.updated_at) >= cut; });
},
get chatOlder() {
var cut = Date.now() - 30 * 86400000;
return this.chatHistory.filter(function(c) { return fdTime(c.updated_at) < cut; });
},
loadChatHistory() {
var self = this;
this.chatLoading = true;
return fetch('/api/agent/conversations', { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(d) {
self.chatHistory = (d && d.conversations) || [];
self.chatLoading = false;
})
.catch(function() { self.chatLoading = false; });
},
chatOpenConversation(id) {
if (window.fdAgent && window.fdAgent.openConversation) window.fdAgent.openConversation(id);
else this.agentOpen();
},
chatNewConversation() {
if (window.fdAgent && window.fdAgent.open) window.fdAgent.open();
else this.agentNew();
},
// ── Meeting tab ──
meetings: [],
meetingsLoading: false,
meetingFilter: 'upcoming',
get meetingVisible() {
if (this.meetingFilter === 'today') {
return this.meetings.filter(function(e) { return e.today; });
}
return this.meetings.filter(function(e) { return !e.today; });
},
get meetingToday() { return this.meetings.filter(function(e) { return e.today; }); },
loadMeetings(force) {
var self = this;
if (!force && this.meetings.length) return Promise.resolve();
this.meetingsLoading = true;
return fetch('/api/v2/meetings/upcoming?days=30', { credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : { events: [] }; })
.then(function(d) {
self.meetings = (d && d.events) || [];
self.meetingsLoading = false;
})
.catch(function() { self.meetings = []; self.meetingsLoading = false; });
},
meetingOpen(ev) {
var target = ev.url || ('/db/' + ev.collection_id);
if (window.fdNavigate) window.fdNavigate(target);
else window.location.href = target;
},
meetingTranscribe() {
var input = this.$refs && this.$refs.meetingAudio;
if (input) input.click();
},
meetingNote() {
if (window.fdAgent && window.fdAgent.ask) {
window.fdAgent.ask('/meeting-note');
} else if (window.fdAgent && window.fdAgent.open) {
window.fdAgent.open();
}
},
meetingUpload(ev) {
var self = this;
var input = ev.target;
var file = input && input.files && input.files[0];
if (!file) return;
var title = 'Podcast ' + new Date().toISOString().slice(0, 10);
// A transcript row is attached to a page: create the meeting page first,
// then upload the audio against it.
fetch('/api/local-workspace/items', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: title, type: 'page' })
})
.then(function(r) { if (!r.ok) throw new Error('page'); return r.json(); })
.then(function(page) {
var fd = new FormData();
fd.append('audio', file);
fd.append('page_id', String(page.id));
return fetch('/api/v2/meetings/transcribe', {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-CSRF-Token': self.getCsrfToken() },
body: fd
}).then(function(r) {
if (!r.ok) throw new Error('transcribe');
return r.json();
}).then(function() { return page; });
})
.then(function(page) {
self.toast('Audio uploaded — transcription attached to "' + title + '"', 'success');
window.location.href = '/pages/' + page.id;
})
.catch(function() { self.toast('Upload failed', 'error'); })
.then(function() { if (input) input.value = ''; });
},
// ── Inbox tab ──
inboxItems: [],
inboxUnread: 0,
inboxLoading: false,
inboxFilter: 'all',
get inboxVisible() {
if (this.inboxFilter === 'unread') {
return this.inboxItems.filter(function(n) { return !n.is_read; });
}
return this.inboxItems;
},
loadInbox() {
var self = this;
this.inboxLoading = true;
return fetch('/api/notifications?limit=50', { credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : { notifications: [], unread: 0 }; })
.then(function(d) {
self.inboxItems = (d && d.notifications) || [];
self.inboxUnread = (d && d.unread) || 0;
self.inboxLoading = false;
})
.catch(function() { self.inboxLoading = false; });
},
inboxOpen(n) {
if (!n.is_read) {
var self = this;
fetch('/api/notifications/read', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ id: n.id })
}).then(function() { return self.loadInbox(); });
}
if (n.url) window.location.href = n.url;
},
inboxMarkAllRead() {
var self = this;
fetch('/api/notifications/read', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({})
}).then(function() { return self.loadInbox(); });
},
contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' },
contextActions: {},
showNewPageMenu: false,