diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index 9d803dca..ef6f0dc1 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -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 { diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 86c00190..6083cd32 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -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')); + `); } } diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index 276b9d17..cdd49e78 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -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(); for (const match of body.matchAll(/(^|\s)@([A-Za-z][A-Za-z0-9_-]{1,63})/g)) { diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index d76c80b5..16048d74 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -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, diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index ed3b9915..bd1c260c 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -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), diff --git a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx index 3896c440..54c95791 100644 --- a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx @@ -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], diff --git a/AgentCompany-New/src/renderer/src/shared/Shell.tsx b/AgentCompany-New/src/renderer/src/shared/Shell.tsx index 02adb01e..4d76d9b6 100644 --- a/AgentCompany-New/src/renderer/src/shared/Shell.tsx +++ b/AgentCompany-New/src/renderer/src/shared/Shell.tsx @@ -136,10 +136,29 @@ function renderBadge(value: number | undefined): ReactNode { } function computeBadges(snapshot: AppSnapshot, orgId: string): Partial> { + 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(); + 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; +} diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index 0f800962..4210ae52 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -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; diff --git a/AgentCompany-New/src/shared/desktop-api.ts b/AgentCompany-New/src/shared/desktop-api.ts index 30b7dd71..2b33fb95 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -8,6 +8,7 @@ import type { CreateApprovalInput, CreateChatSpaceInput, CreateMigrationReportInput, + MarkChatSpaceReadInput, CreateOrganizationInput, CreateProviderResponseInput, CreateScheduledPostInput, @@ -40,6 +41,7 @@ export interface DesktopApi { decideApproval(input: DecideApprovalInput): Promise>; createChatSpace(input: CreateChatSpaceInput): Promise>; sendMessage(input: SendMessageInput): Promise>; + markChatSpaceRead(input: MarkChatSpaceReadInput): Promise>; saveProviderCredential(input: SaveProviderCredentialInput): Promise>; createProviderResponse(input: CreateProviderResponseInput): Promise>; importSkillDirectory(input: ImportSkillDirectoryInput): Promise>; diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 6d74b857..91cc4ef9 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -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[]; diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index beb164dd..70e5dbfd 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -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",