Track per-employee chat read state and surface real unread counts
Adds chat_read_state migration (employee_id + space_id, last read message + timestamp) wired through ChatService.markSpaceRead, IPC markChatSpaceRead, and an effect in ChatPage that flushes the active space whenever the latest message id changes. Shell.computeBadges now derives the Messages badge from (message.createdAt > read_state.last_read_at) minus messages the viewer sent, so the sidebar shows true unread instead of total throughput.
This commit is contained in:
1 parent
b44e0e3657
commit
1f78ae6f2e
11 files changed
+100
-3
No files matched your search
@@ -74,6 +74,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createChatSpace, (_event, input) => mutate(() => chat.createSpace(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createProviderResponse, async (_event, input) => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
const SCHEMA_VERSION = 4;
|
||||
const SCHEMA_VERSION = 5;
|
||||
|
||||
export function installSchema(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
@@ -484,5 +484,24 @@ export function installSchema(db: DatabaseSync): void {
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (4, 'files_pinned_and_scheduled_posts', datetime('now'));
|
||||
`);
|
||||
currentVersion = 4;
|
||||
}
|
||||
|
||||
if (currentVersion < 5) {
|
||||
db.exec(`
|
||||
create table if not exists chat_read_state (
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
employee_id text not null references employees(id) on delete cascade,
|
||||
space_id text not null references chat_spaces(id) on delete cascade,
|
||||
last_read_message_id text references chat_messages(id) on delete set null,
|
||||
last_read_at text not null,
|
||||
primary key (employee_id, space_id)
|
||||
);
|
||||
|
||||
create index if not exists idx_chat_read_state_org on chat_read_state(org_id, employee_id);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (5, 'chat_read_state', datetime('now'));
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateChatSpaceInput, SendMessageInput } from "../../shared/commands";
|
||||
import type { CreateChatSpaceInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
@@ -101,6 +101,28 @@ export class ChatService {
|
||||
return id;
|
||||
}
|
||||
|
||||
markSpaceRead(input: MarkChatSpaceReadInput): void {
|
||||
const space = this.db.prepare("select id from chat_spaces where id = ? and org_id = ?")
|
||||
.get(input.spaceId, input.orgId) as { id: string } | undefined;
|
||||
if (!space) throw new Error("Chat space was not found.");
|
||||
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?")
|
||||
.get(input.employeeId, input.orgId) as { id: string } | undefined;
|
||||
if (!employee) throw new Error("Employee was not found.");
|
||||
const latest = this.db.prepare(`
|
||||
select id from chat_messages
|
||||
where space_id = ? and org_id = ?
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`).get(input.spaceId, input.orgId) as { id: string } | undefined;
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into chat_read_state(org_id, employee_id, space_id, last_read_message_id, last_read_at)
|
||||
values (?, ?, ?, ?, ?)
|
||||
on conflict(employee_id, space_id)
|
||||
do update set last_read_message_id = excluded.last_read_message_id, last_read_at = excluded.last_read_at
|
||||
`).run(input.orgId, input.employeeId, input.spaceId, latest?.id ?? null, now);
|
||||
}
|
||||
|
||||
private recordMentions(messageId: string, body: string): void {
|
||||
const seen = new Set<string>();
|
||||
for (const match of body.matchAll(/(^|\s)@([A-Za-z][A-Za-z0-9_-]{1,63})/g)) {
|
||||
|
||||
@@ -64,6 +64,7 @@ export class SnapshotService {
|
||||
chatThreads: list(this.db, "chat_threads", "updated_at desc"),
|
||||
chatAttachments: list(this.db, "chat_attachments", "message_id asc"),
|
||||
chatPresence: list(this.db, "chat_presence", "last_seen_at desc"),
|
||||
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
|
||||
files,
|
||||
scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"),
|
||||
providers,
|
||||
|
||||
@@ -17,6 +17,7 @@ const api: DesktopApi = {
|
||||
decideApproval: (input) => invoke(IPC_CHANNELS.decideApproval, input),
|
||||
createChatSpace: (input) => invoke(IPC_CHANNELS.createChatSpace, input),
|
||||
sendMessage: (input) => invoke(IPC_CHANNELS.sendMessage, input),
|
||||
markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input),
|
||||
saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input),
|
||||
createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input),
|
||||
importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { Field } from "../shared/ui";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import { ChatSidebar } from "./chat/ChatSidebar";
|
||||
@@ -46,6 +46,22 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
? selectedSpaceId
|
||||
: fallbackSpaceId;
|
||||
const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? null;
|
||||
const latestActiveMessageId = useMemo(() => {
|
||||
if (!activeSpace) return null;
|
||||
const inSpace = messagesAll.filter((message) => message.spaceId === activeSpace.id);
|
||||
if (inSpace.length === 0) return null;
|
||||
return inSpace[inSpace.length - 1].id;
|
||||
}, [messagesAll, activeSpace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSpace || !latestActiveMessageId) return;
|
||||
void actions.run(() => window.agentCompany.markChatSpaceRead({
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
spaceId: activeSpace.id,
|
||||
}));
|
||||
}, [activeSpace, latestActiveMessageId, orgId, employee.id, actions]);
|
||||
|
||||
const messagesInSpace = useMemo(
|
||||
() => (activeSpace ? messagesAll.filter((m) => m.spaceId === activeSpace.id) : []),
|
||||
[messagesAll, activeSpace],
|
||||
|
||||
@@ -136,10 +136,29 @@ function renderBadge(value: number | undefined): ReactNode {
|
||||
}
|
||||
|
||||
function computeBadges(snapshot: AppSnapshot, orgId: string): Partial<Record<RouteId, number>> {
|
||||
const employee = snapshot.employees.find((item) => item.orgId === orgId);
|
||||
const tasks = snapshot.tasks.filter((item) => item.orgId === orgId);
|
||||
const approvals = snapshot.approvals.filter((item) => item.orgId === orgId);
|
||||
return {
|
||||
work: tasks.filter((task) => task.status !== "done").length,
|
||||
chat: employee ? countUnreadMessages(snapshot, orgId, employee.id) : 0,
|
||||
approvals: approvals.filter((approval) => approval.state === "pending").length,
|
||||
};
|
||||
}
|
||||
|
||||
function countUnreadMessages(snapshot: AppSnapshot, orgId: string, employeeId: string): number {
|
||||
const readByspace = new Map<string, string>();
|
||||
for (const state of snapshot.chatReadStates) {
|
||||
if (state.orgId !== orgId || state.employeeId !== employeeId) continue;
|
||||
readByspace.set(state.spaceId, state.lastReadAt);
|
||||
}
|
||||
let unread = 0;
|
||||
for (const message of snapshot.chatMessages) {
|
||||
if (message.orgId !== orgId) continue;
|
||||
if (message.senderType === "employee" && message.senderId === employeeId) continue;
|
||||
const lastRead = readByspace.get(message.spaceId);
|
||||
if (lastRead && message.createdAt <= lastRead) continue;
|
||||
unread += 1;
|
||||
}
|
||||
return unread;
|
||||
}
|
||||
@@ -89,6 +89,12 @@ export interface CreateChatSpaceInput {
|
||||
visibility: "workspace" | "restricted";
|
||||
}
|
||||
|
||||
export interface MarkChatSpaceReadInput {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export interface SaveProviderCredentialInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CreateApprovalInput,
|
||||
CreateChatSpaceInput,
|
||||
CreateMigrationReportInput,
|
||||
MarkChatSpaceReadInput,
|
||||
CreateOrganizationInput,
|
||||
CreateProviderResponseInput,
|
||||
CreateScheduledPostInput,
|
||||
@@ -40,6 +41,7 @@ export interface DesktopApi {
|
||||
decideApproval(input: DecideApprovalInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createChatSpace(input: CreateChatSpaceInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
sendMessage(input: SendMessageInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
markChatSpaceRead(input: MarkChatSpaceReadInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createProviderResponse(input: CreateProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
importSkillDirectory(input: ImportSkillDirectoryInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -224,6 +224,14 @@ export interface ChatPresenceRecord {
|
||||
lastSeenAt: string;
|
||||
}
|
||||
|
||||
export interface ChatReadStateRecord {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
spaceId: string;
|
||||
lastReadMessageId: string | null;
|
||||
lastReadAt: string;
|
||||
}
|
||||
|
||||
export interface FileObjectRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
@@ -407,6 +415,7 @@ export interface AppSnapshot {
|
||||
chatThreads: ChatThreadRecord[];
|
||||
chatAttachments: ChatAttachmentRecord[];
|
||||
chatPresence: ChatPresenceRecord[];
|
||||
chatReadStates: ChatReadStateRecord[];
|
||||
files: FileObjectRecord[];
|
||||
scheduledPosts: ScheduledPostRecord[];
|
||||
providers: ProviderRecord[];
|
||||
|
||||
@@ -9,6 +9,7 @@ export const IPC_CHANNELS = {
|
||||
decideApproval: "agentcompany:decide-approval",
|
||||
createChatSpace: "agentcompany:create-chat-space",
|
||||
sendMessage: "agentcompany:send-message",
|
||||
markChatSpaceRead: "agentcompany:mark-chat-space-read",
|
||||
saveProviderCredential: "agentcompany:save-provider-credential",
|
||||
createProviderResponse: "agentcompany:create-provider-response",
|
||||
importSkillDirectory: "agentcompany:import-skill-directory",
|
||||
|
||||
Reference in new issue
Block a user