diff --git a/landing/components/common/AppLogo.vue b/landing/components/common/AppLogo.vue
index 8cea3b4c..d8ed900f 100644
--- a/landing/components/common/AppLogo.vue
+++ b/landing/components/common/AppLogo.vue
@@ -1,10 +1,11 @@
();
const localeStore = useLocaleStore();
+// Sync store with actual i18n locale on mount (handles SSG hydration)
+onMounted(() => {
+ if (locale.value && locale.value !== localeStore.current) {
+ localeStore.setLocale(locale.value as string, false);
+ }
+});
+
const flagIconMap: Record = {
en: "circle-flags:us",
ru: "circle-flags:ru"
diff --git a/landing/components/sections/HeroSection.vue b/landing/components/sections/HeroSection.vue
index 84cad3a8..d06cd4c8 100644
--- a/landing/components/sections/HeroSection.vue
+++ b/landing/components/sections/HeroSection.vue
@@ -3,6 +3,7 @@ import { mdiRobotOutline, mdiViewDashboardOutline, mdiOpenSourceInitiative } fro
const { content } = useLandingContent();
const { t } = useI18n();
+const { baseURL } = useRuntimeConfig().app;
@@ -13,7 +14,7 @@ const { t } = useI18n();
`${baseURL}${path.replace(/^\//, '')}`;
+
const screenshots = [
- { src: '/screenshots/1.jpg', alt: 'Kanban board with agent tasks' },
- { src: '/screenshots/2.jpg', alt: 'Agent team communication' },
- { src: '/screenshots/3.png', alt: 'Code review diff view' },
- { src: '/screenshots/4.png', alt: 'Team management dashboard' },
- { src: '/screenshots/5.png', alt: 'Live process monitoring' },
- { src: '/screenshots/6.png', alt: 'Session context analysis' },
- { src: '/screenshots/7.png', alt: 'Cross-team messaging' },
- { src: '/screenshots/8.png', alt: 'Task details and comments' },
- { src: '/screenshots/9.png', alt: 'Built-in code editor' },
+ { src: publicPath('/screenshots/1.jpg'), alt: 'Kanban board with agent tasks' },
+ { src: publicPath('/screenshots/2.jpg'), alt: 'Agent team communication' },
+ { src: publicPath('/screenshots/3.png'), alt: 'Code review diff view' },
+ { src: publicPath('/screenshots/4.png'), alt: 'Team management dashboard' },
+ { src: publicPath('/screenshots/5.png'), alt: 'Live process monitoring' },
+ { src: publicPath('/screenshots/6.png'), alt: 'Session context analysis' },
+ { src: publicPath('/screenshots/7.png'), alt: 'Cross-team messaging' },
+ { src: publicPath('/screenshots/8.png'), alt: 'Task details and comments' },
+ { src: publicPath('/screenshots/9.png'), alt: 'Built-in code editor' },
];
const swiperRef = ref(null);
diff --git a/landing/composables/useLocation.ts b/landing/composables/useLocation.ts
index 7bc1aab8..815d5d38 100644
--- a/landing/composables/useLocation.ts
+++ b/landing/composables/useLocation.ts
@@ -16,15 +16,19 @@ export const useLocation = () => {
};
const initLocale = () => {
+ // Sync store with actual i18n locale (already resolved from route by nuxt-i18n)
+ const currentLocale = i18n?.locale?.value || "en";
+
if (cookie.value) {
- localeStore.setLocale(cookie.value, false);
- if (i18n?.setLocale) {
- i18n.setLocale(cookie.value);
- } else if (i18n?.locale?.value) {
- i18n.locale.value = cookie.value;
+ // Cookie exists — sync store, but don't override route-based locale
+ localeStore.setLocale(currentLocale, false);
+ if (cookie.value !== currentLocale) {
+ cookie.value = currentLocale;
}
return;
}
+
+ // No cookie — detect from browser and set
const detected = getBrowserLocale();
localeStore.setLocale(detected, false);
if (i18n?.setLocale) {
diff --git a/landing/nuxt.config.ts b/landing/nuxt.config.ts
index 324a3bca..148c0d2d 100644
--- a/landing/nuxt.config.ts
+++ b/landing/nuxt.config.ts
@@ -7,6 +7,7 @@ declare const process: any;
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://claude-agent-teams.dev";
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui";
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
+const baseURL = process.env.NUXT_APP_BASE_URL || "/";
export default defineNuxtConfig({
compatibilityDate: "2026-01-19",
@@ -15,9 +16,10 @@ export default defineNuxtConfig({
inlineSSRStyles: false
},
app: {
+ baseURL,
head: {
link: [
- { rel: "icon", type: "image/x-icon", href: "/favicon.ico" },
+ { rel: "icon", type: "image/x-icon", href: `${baseURL}favicon.ico` },
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
{ rel: "preload", href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap", as: "style" },
diff --git a/landing/public/favicon-32.png b/landing/public/favicon-32.png
index 29f7bc78..b07f1550 100644
Binary files a/landing/public/favicon-32.png and b/landing/public/favicon-32.png differ
diff --git a/landing/public/favicon.ico b/landing/public/favicon.ico
index 4274409a..99933316 100644
Binary files a/landing/public/favicon.ico and b/landing/public/favicon.ico differ
diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts
index 78d6350e..11e5f1f9 100644
--- a/src/main/services/team/TeamInboxReader.ts
+++ b/src/main/services/team/TeamInboxReader.ts
@@ -1,7 +1,6 @@
-import { createHash } from 'crypto';
-
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
+import { createHash } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
diff --git a/src/renderer/components/team/attachments/AttachmentDisplay.tsx b/src/renderer/components/team/attachments/AttachmentDisplay.tsx
index 33ff6d87..ce4e151b 100644
--- a/src/renderer/components/team/attachments/AttachmentDisplay.tsx
+++ b/src/renderer/components/team/attachments/AttachmentDisplay.tsx
@@ -1,10 +1,8 @@
import { useEffect, useState } from 'react';
-import { Loader2 } from 'lucide-react';
-
-import { isImageMime } from '@renderer/utils/attachmentUtils';
-
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
+import { isImageMime } from '@renderer/utils/attachmentUtils';
+import { Loader2 } from 'lucide-react';
import { AttachmentThumbnail } from './AttachmentThumbnail';
import { ImageLightbox } from './ImageLightbox';
diff --git a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx
index d81981f7..a1f9a3e9 100644
--- a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx
+++ b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx
@@ -1,8 +1,7 @@
+import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { formatFileSize, isImageMime } from '@renderer/utils/attachmentUtils';
import { Ban, X } from 'lucide-react';
-import { FileIcon } from '@renderer/components/team/editor/FileIcon';
-
import { AttachmentThumbnail } from './AttachmentThumbnail';
import type { AttachmentPayload } from '@shared/types';
diff --git a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx
index 873c4488..7bef2cd3 100644
--- a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx
+++ b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
-import { AlertCircle, X } from 'lucide-react';
-
import { isImageMime } from '@renderer/utils/attachmentUtils';
+import { AlertCircle, X } from 'lucide-react';
import { AttachmentPreviewItem } from './AttachmentPreviewItem';
import { ImageLightbox } from './ImageLightbox';
diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx
index 6879c46e..f1c512fc 100644
--- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx
+++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx
@@ -124,6 +124,7 @@ export const SendMessageDialog = ({
addFiles,
removeAttachment,
clearAttachments,
+ clearError: clearAttachmentError,
handlePaste,
handleDrop,
} = useAttachments({ persistenceKey: `sendMessage:${teamName}:attachments` });
@@ -417,6 +418,7 @@ export const SendMessageDialog = ({
attachments={attachments}
onRemove={removeAttachment}
error={attachmentError ?? fileRestrictionError}
+ onDismissError={clearAttachmentError}
disabled={attachmentsBlocked}
disabledHint="File attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
/>
diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx
index 55cdcda6..bbf69ae8 100644
--- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx
+++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx
@@ -88,12 +88,14 @@ export const TaskCommentInput = ({
!addingComment;
const addFiles = useCallback(
- (files: FileList | File[]) => {
+ async (files: FileList | File[]) => {
setAttachError(null);
const fileArray = Array.from(files);
+
+ // 1. Separate unsupported files → path prepend
+ const supported: File[] = [];
for (const file of fileArray) {
if (categorizeFile(file) === 'unsupported') {
- // Insert absolute file path into comment text for unsupported types
const filePath = (file as { path?: string }).path;
if (filePath) {
const current = draft.value;
@@ -101,37 +103,51 @@ export const TaskCommentInput = ({
}
continue;
}
+ if (file.size === 0) {
+ setAttachError(`File "${file.name}" is empty`);
+ continue;
+ }
if (file.size > MAX_FILE_SIZE) {
setAttachError(
`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`
);
continue;
}
- const reader = new FileReader();
- reader.onload = () => {
- const result = reader.result as string;
- const base64 = result.split(',')[1];
- if (!base64) return;
- const id = crypto.randomUUID();
- setPendingAttachments((prev) => {
- if (prev.length >= MAX_ATTACHMENTS) {
- setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
- return prev;
- }
- return [
- ...prev,
- {
- id,
- filename: file.name,
- mimeType: getEffectiveMimeType(file),
- base64Data: base64,
- previewUrl: result,
- size: file.size,
- },
- ];
- });
- };
- reader.readAsDataURL(file);
+ supported.push(file);
+ }
+
+ if (supported.length === 0) return;
+
+ // 2. Read all files sequentially to avoid race condition with MAX_ATTACHMENTS
+ for (const file of supported) {
+ const result = await new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = () => resolve(null);
+ reader.readAsDataURL(file);
+ });
+ if (!result) continue;
+ const base64 = result.split(',')[1];
+ if (!base64) continue;
+
+ const id = crypto.randomUUID();
+ setPendingAttachments((prev) => {
+ if (prev.length >= MAX_ATTACHMENTS) {
+ setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
+ return prev;
+ }
+ return [
+ ...prev,
+ {
+ id,
+ filename: file.name,
+ mimeType: getEffectiveMimeType(file),
+ base64Data: base64,
+ previewUrl: result,
+ size: file.size,
+ },
+ ];
+ });
}
},
[draft]
@@ -194,12 +210,12 @@ export const TaskCommentInput = ({
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile();
- if (file && categorizeFile(file) !== 'unsupported') pastedFiles.push(file);
+ if (file) pastedFiles.push(file);
}
}
if (pastedFiles.length > 0) {
e.preventDefault();
- addFiles(pastedFiles);
+ void addFiles(pastedFiles);
}
},
[addFiles]
@@ -318,7 +334,7 @@ export const TaskCommentInput = ({
multiple
className="hidden"
onChange={(e) => {
- if (e.target.files) addFiles(e.target.files);
+ if (e.target.files) void addFiles(e.target.files);
e.target.value = '';
}}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx
index d1a14eaa..e8105b08 100644
--- a/src/renderer/components/team/messages/MessageComposer.tsx
+++ b/src/renderer/components/team/messages/MessageComposer.tsx
@@ -255,7 +255,7 @@ export const MessageComposer = ({
// const leadContext = useStore((s) =>
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
// );
- const supportsAttachments = isLeadRecipient && !isCrossTeam;
+ const supportsAttachments = isLeadRecipient && !isCrossTeam && !!isTeamAlive;
const canAttach = supportsAttachments && draft.canAddMore;
const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments;
const canSend =
@@ -370,22 +370,22 @@ export const MessageComposer = ({
e.preventDefault();
dragCounterRef.current = 0;
setIsDragOver(false);
- if (!isLeadRecipient) {
+ if (!supportsAttachments) {
const files = e.dataTransfer?.files;
if (files?.length) {
showFileRestrictionError();
}
return;
}
- if (canAttach) draftHandleDrop(e);
+ draftHandleDrop(e);
},
- [isLeadRecipient, canAttach, draftHandleDrop, showFileRestrictionError]
+ [supportsAttachments, draftHandleDrop, showFileRestrictionError]
);
const { handlePaste: draftHandlePaste } = draft;
const handlePasteWrapper = useCallback(
(e: React.ClipboardEvent) => {
- if (!isLeadRecipient) {
+ if (!supportsAttachments) {
const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file');
if (hasFiles) {
e.preventDefault();
@@ -393,9 +393,9 @@ export const MessageComposer = ({
}
return;
}
- if (canAttach) draftHandlePaste(e);
+ draftHandlePaste(e);
},
- [isLeadRecipient, canAttach, draftHandlePaste, showFileRestrictionError]
+ [supportsAttachments, draftHandlePaste, showFileRestrictionError]
);
const remaining = MAX_TEXT_LENGTH - trimmed.length;
diff --git a/src/renderer/hooks/useAttachments.ts b/src/renderer/hooks/useAttachments.ts
index 48210114..5816cce7 100644
--- a/src/renderer/hooks/useAttachments.ts
+++ b/src/renderer/hooks/useAttachments.ts
@@ -1,13 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { draftStorage } from '@renderer/services/draftStorage';
-import { categorizeFile } from '@shared/constants/attachments';
import {
fileToAttachmentPayload,
MAX_FILES,
MAX_TOTAL_SIZE,
validateAttachment,
} from '@renderer/utils/attachmentUtils';
+import { categorizeFile } from '@shared/constants/attachments';
import type { AttachmentPayload } from '@shared/types';
diff --git a/src/renderer/hooks/useComposerDraft.ts b/src/renderer/hooks/useComposerDraft.ts
index 81be33f6..bfdc2484 100644
--- a/src/renderer/hooks/useComposerDraft.ts
+++ b/src/renderer/hooks/useComposerDraft.ts
@@ -17,13 +17,13 @@ import {
type ComposerDraftSnapshot,
composerDraftStorage,
} from '@renderer/services/composerDraftStorage';
-import { categorizeFile } from '@shared/constants/attachments';
import {
fileToAttachmentPayload,
MAX_FILES,
MAX_TOTAL_SIZE,
validateAttachment,
} from '@renderer/utils/attachmentUtils';
+import { categorizeFile } from '@shared/constants/attachments';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { AgentActionMode, AttachmentPayload } from '@shared/types';
diff --git a/src/renderer/hooks/useStableTeamMentionMeta.ts b/src/renderer/hooks/useStableTeamMentionMeta.ts
index 235f5e2f..9ec9903c 100644
--- a/src/renderer/hooks/useStableTeamMentionMeta.ts
+++ b/src/renderer/hooks/useStableTeamMentionMeta.ts
@@ -1,4 +1,4 @@
-import { useMemo, useRef } from 'react';
+import { useMemo } from 'react';
import type { TeamSummary } from '@shared/types';
@@ -35,29 +35,6 @@ function buildTeamMentionEntries(teams: readonly TeamSummary[]): TeamMentionEntr
.sort(compareTeamMentionEntries);
}
-function areTeamMentionEntriesEqual(
- prev: readonly TeamMentionEntry[],
- next: readonly TeamMentionEntry[]
-): boolean {
- if (prev === next) return true;
- if (prev.length !== next.length) return false;
-
- for (let i = 0; i < prev.length; i++) {
- const prevEntry = prev[i];
- const nextEntry = next[i];
- if (
- prevEntry.teamName !== nextEntry.teamName ||
- prevEntry.displayName !== nextEntry.displayName ||
- prevEntry.color !== nextEntry.color ||
- prevEntry.deletedAt !== nextEntry.deletedAt
- ) {
- return false;
- }
- }
-
- return true;
-}
-
function buildTeamMentionMeta(entries: readonly TeamMentionEntry[]): TeamMentionMeta {
if (entries.length === 0) {
return { teamNames: EMPTY_TEAM_NAMES, teamColorByName: EMPTY_TEAM_COLOR_MAP };
@@ -84,24 +61,8 @@ function buildTeamMentionMeta(entries: readonly TeamMentionEntry[]): TeamMention
export function useStableTeamMentionMeta(teams: readonly TeamSummary[]): TeamMentionMeta {
const entries = useMemo(() => buildTeamMentionEntries(teams), [teams]);
- const stableRef = useRef<{ entries: readonly TeamMentionEntry[]; value: TeamMentionMeta } | null>(
- null
- );
- // Intentional ref-as-cache pattern: avoids allocating a new object every
- // render while still returning a referentially-stable value when the
- // underlying data hasn't changed.
+ const meta = useMemo(() => buildTeamMentionMeta(entries), [entries]);
- if (
- stableRef.current === null ||
- !areTeamMentionEntriesEqual(stableRef.current.entries, entries)
- ) {
- stableRef.current = {
- entries,
- value: buildTeamMentionMeta(entries),
- };
- }
-
- // eslint-disable-next-line react-hooks/refs -- stable ref cache pattern
- return stableRef.current.value;
+ return meta;
}