Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3c3d259
chore(go-deps:backend)(deps): bump the go-dependencies group with 3 u…
dependabot[bot] Aug 24, 2026
465210a
chore(npm-deps:website)(deps-dev): bump vite
dependabot[bot] Aug 24, 2026
b08169d
chore(npm-deps:frontend)(deps-dev): bump the frontend-dependencies group
dependabot[bot] Aug 24, 2026
361be04
fix(search): explain AI results and open selected articles
marcomarcogd Aug 24, 2026
15b413f
fix(settings): update AI usage limit display immediately
marcomarcogd Aug 24, 2026
52e0a68
fix(database): retry locked article batch writes
marcomarcogd Aug 24, 2026
70a1cab
release: Upgrade version to v1.3.28
WCY-dt Aug 24, 2026
e982951
Merge pull request #1024 from DevXDojo/dependabot/go_modules/go-depen…
WCY-dt Aug 24, 2026
01c494d
Merge pull request #1025 from DevXDojo/dependabot/npm_and_yarn/websit…
WCY-dt Aug 24, 2026
f2fd3f8
Merge pull request #1026 from DevXDojo/dependabot/npm_and_yarn/fronte…
WCY-dt Aug 24, 2026
9d0f334
fix(ui): prevent long toast messages from overflowing
marcomarcogd Aug 25, 2026
b97de0f
fix(feeds): restore feed management table layout
marcomarcogd Aug 25, 2026
a304679
Merge pull request #1030 from marcomarcogd/codex/fix-ai-search-releva…
WCY-dt Aug 26, 2026
a67708d
Merge pull request #1031 from marcomarcogd/codex/fix-ai-usage-limit-l…
WCY-dt Aug 26, 2026
69453da
Merge pull request #1032 from marcomarcogd/codex/fix-article-batch-lo…
WCY-dt Aug 26, 2026
fcd35db
Merge pull request #1037 from marcomarcogd/codex/fix-toast-overflow
WCY-dt Aug 26, 2026
50153af
doc: Update changelog
WCY-dt Aug 26, 2026
d1f9199
Merge branch 'release/v1.3.28' of https://github.com/WCY-dt/MrRSS int…
WCY-dt Aug 26, 2026
085e9ff
Merge pull request #1040 from marcomarcogd/codex/fix-feed-management-…
WCY-dt Aug 26, 2026
12a00f1
fix(ai): return user-friendly errors across AI features
marcomarcogd Aug 25, 2026
89a76b0
fix(chat): persist article conversations
marcomarcogd Aug 25, 2026
2ade02a
Merge pull request #1039 from marcomarcogd/codex/fix-chat-persistence
WCY-dt Aug 27, 2026
4263eae
Merge pull request #1035 from marcomarcogd/codex/fix-ai-friendly-errors
WCY-dt Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to MrRSS will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.3.28] - 2026-08-29

### Added

- Persist article AI chat exchanges in the existing chat session tables so conversations remain accessible after creating a new conversation, switching sessions, or restarting the app. (#1038) (@marcomarcogd)

### Changed

- Makes AI search results explainable and navigable. (#1027) (@marcomarcogd)
- Updates the AI usage card immediately while the user edits the token limit and refreshes actual usage while the AI settings page remains visible. (#1028) (@marcomarcogd)
- Classifies AI provider and transport failures into stable, user-facing error codes and short localized messages. (#1034) (@marcomarcogd)

### Fixed

- Makes SaveArticles atomic under SQLite write contention. (#1029) (@marcomarcogd)
- Keep toast notifications inside narrow application windows even when messages contain long continuous text. (#1036) (@marcomarcogd)
- Restore a stable, aligned feed-management table in Settings and keep row interaction inside the settings workflow. (#1023) (@marcomarcogd)

## [1.3.27] - 2026-08-22

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<strong>English</strong> | <a href="README_zh.md">简体中文</a>
</p>

[![Version](https://img.shields.io/badge/version-1.3.27-blue.svg)](https://github.com/DevXDojo/MrRSS/releases)
[![Version](https://img.shields.io/badge/version-1.3.28-blue.svg)](https://github.com/DevXDojo/MrRSS/releases)
[![License](https://img.shields.io/badge/license-GPLv3-green.svg)](LICENSE)
[![Go](https://img.shields.io/badge/Go-1.27+-00ADD8?logo=go)](https://go.dev/)
[![Wails](https://img.shields.io/badge/Wails-v3%20alpha-red)](https://wails.io/)
Expand Down
2 changes: 1 addition & 1 deletion README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<a href="README.md">English</a> | <strong>简体中文</strong>
</p>

[![Version](https://img.shields.io/badge/version-1.3.27-blue.svg)](https://github.com/DevXDojo/MrRSS/releases)
[![Version](https://img.shields.io/badge/version-1.3.28-blue.svg)](https://github.com/DevXDojo/MrRSS/releases)
[![License](https://img.shields.io/badge/license-GPLv3-green.svg)](LICENSE)
[![Go](https://img.shields.io/badge/Go-1.27+-00ADD8?logo=go)](https://go.dev/)
[![Wails](https://img.shields.io/badge/Wails-v3%20alpha-red)](https://wails.io/)
Expand Down
4 changes: 2 additions & 2 deletions build/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ info:
description: "A modern, standalone RSS reader" # The application description
copyright: "Copyright © 2026" # Copyright text
comments: "Built with Wails" # Comments
version: "1.3.27" # The application version
version: "1.3.28" # The application version

# iOS build configuration (uncomment to customise iOS project generation)
# Note: Keys under `ios` OVERRIDE values under `info` when set.
Expand All @@ -21,7 +21,7 @@ info:
# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
# displayName: "MrRSS"
# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
# version: "1.3.27"
# version: "1.3.28"
# # The company/organisation name for templates and project settings
# company: "Ch3nyang"
# # Additional comments to embed in Info.plist metadata
Expand Down
2 changes: 1 addition & 1 deletion build/linux/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ tasks:
cmds:
- cd {{.BIN_DIR}} && tar -czf {{.APP_NAME}}-{{.VERSION}}-linux-{{.ARCH}}.tar.gz {{.APP_NAME}}
vars:
VERSION: '1.3.27'
VERSION: '1.3.28'
ARCH: '{{.ARCH | default "amd64"}}'

generate:dotdesktop:
Expand Down
2 changes: 1 addition & 1 deletion build/linux/nfpm/nfpm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
name: "MrRSS"
arch: ${GOARCH}
platform: "linux"
version: "1.3.27"
version: "1.3.28"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
Expand Down
2 changes: 1 addition & 1 deletion docs/SERVER_MODE/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"name": "GPL-3.0",
"url": "https://www.gnu.org/licenses/gpl-3.0.en.html"
},
"version": "1.3.27"
"version": "1.3.28"
},
"host": "localhost:1234",
"basePath": "/api",
Expand Down
296 changes: 296 additions & 0 deletions frontend/cypress/e2e/article-operations.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,300 @@ describe('Article Operations', () => {
}
});
});

it('should explain AI search results and keep list and card navigation in search context', () => {
const settingsState: Record<string, string> = {
language: 'en-US',
theme: 'light',
layout_mode: 'normal',
default_view_mode: 'rendered',
ai_search_enabled: 'true',
translation_mode: 'off',
summary_enabled: 'false',
full_text_fetch_enabled: 'false',
update_check_enabled: 'false',
};
const feed = {
id: 1,
title: 'Search Feed',
url: 'https://example.com/feed.xml',
category: '',
article_view_mode: 'global',
};
const timelineArticle = {
id: 1,
feed_id: 1,
feed_title: feed.title,
title: 'Timeline article outside search results',
url: 'https://example.com/timeline',
published_at: '2026-08-20T00:00:00Z',
is_read: false,
is_favorite: false,
is_hidden: false,
is_read_later: false,
};
const searchArticles = [101, 102, 103].map((id, index) => ({
id,
feed_id: 1,
feed_title: feed.title,
title: `Search result ${index + 1}`,
url: `https://example.com/search/${id}`,
published_at: `2026-08-${23 - index}T00:00:00Z`,
is_read: false,
is_favorite: false,
is_hidden: false,
is_read_later: false,
relevance_score: 90 - index,
matched_terms: ['privacy'],
matched_fields: index === 0 ? ['title', 'summary'] : ['content'],
excerpt: `This privacy excerpt explains result ${index + 1}`,
}));

cy.intercept('/api/**', { statusCode: 200, body: {} });
cy.intercept('GET', '/api/settings', (req) => {
req.reply({ statusCode: 200, body: settingsState });
});
cy.intercept('GET', '/api/feeds', { statusCode: 200, body: [feed] }).as('searchFeeds');
cy.intercept('GET', '/api/tags', { statusCode: 200, body: [] });
cy.intercept('GET', '/api/saved-filters', { statusCode: 200, body: [] });
cy.intercept(
{ method: 'GET', pathname: '/api/articles' },
{ statusCode: 200, body: [timelineArticle] }
).as('timelineArticles');
cy.intercept('GET', '/api/articles/unread-counts', { statusCode: 200, body: {} });
cy.intercept('GET', '/api/articles/filter-counts', { statusCode: 200, body: {} });
cy.intercept('GET', '/api/progress', { statusCode: 200, body: { is_running: false } });
cy.intercept('POST', '/api/ai/search', (req) => {
const noResults = req.body?.query === 'nothing matches';
req.reply({
statusCode: 200,
body: {
success: true,
articles: noResults ? [] : searchArticles,
total_count: noResults ? 0 : searchArticles.length,
},
});
}).as('aiSearch');
cy.intercept('GET', '/api/articles/content*', (req) => {
req.reply({
statusCode: 200,
body: { content: `<p>Body for search result ${req.query.id}</p>`, cached: true },
});
}).as('searchArticleContent');
cy.intercept('POST', '/api/articles/read*', { statusCode: 200, body: { success: true } });
cy.intercept('POST', '/api/articles/favorite*', {
statusCode: 200,
body: { success: true },
});
cy.intercept('POST', '/api/articles/toggle-read-later*', {
statusCode: 200,
body: { success: true },
});

cy.window().then((win) => win.localStorage.setItem('showOnlyUnread', 'true'));
cy.reload();
cy.wait('@searchFeeds');
cy.wait('@timelineArticles');

cy.get('input[placeholder="Describe what you want to find..."]').type('privacy');
cy.contains('button', /^AI Search$/).click();
cy.wait('@aiSearch');
cy.contains('Title match').should('be.visible');
cy.contains('Summary match').should('be.visible');
cy.contains('This privacy excerpt explains result 1').should('be.visible');

cy.get('[data-article-id="101"]').click();
cy.wait('@searchArticleContent');
cy.contains('Body for search result 101').should('be.visible');

// The next result is marked read when opened, but must remain selected and
// renderable while the unread-only preference is active.
cy.get('button[title="Next Article"]').click();
cy.wait('@searchArticleContent');
cy.contains('Body for search result 102').should('be.visible');
cy.get('[data-article-id="102"]').should('exist');

// Global shortcuts must use the same ordered search context instead of the
// separately paginated timeline.
cy.get('body').trigger('keydown', { key: 'j' });
cy.wait('@searchArticleContent');
cy.contains('Body for search result 103').should('be.visible');
cy.get('button[title="Previous Article"]').click();
cy.wait('@searchArticleContent');
cy.contains('Body for search result 102').should('be.visible');

cy.contains('button', /^Clear$/).click();
cy.get('input[placeholder="Describe what you want to find..."]').type('nothing matches');
cy.contains('button', /^AI Search$/).click();
cy.wait('@aiSearch');
cy.contains('No articles found matching your search').should('be.visible');

// Card mode uses ArticleDetailModal, which must use the same navigation
// context instead of hiding navigation for off-page search results.
cy.then(() => {
settingsState.layout_mode = 'card';
});
cy.reload();
cy.wait('@searchFeeds');
cy.wait('@timelineArticles');
cy.get('input[placeholder="Describe what you want to find..."]').type('privacy');
cy.contains('button', /^AI Search$/).click();
cy.wait('@aiSearch');
cy.get('.article-card-item[data-article-id="101"]').click();
cy.wait('@searchArticleContent');
cy.contains('Body for search result 101').should('be.visible');
cy.get('button[title="Next Article"]').should('be.visible').click();
cy.wait('@searchArticleContent');
cy.contains('Body for search result 102').should('be.visible');
});

it('should preserve AI chat history across new conversations and request failures', () => {
const settingsState: Record<string, string> = {
language: 'en-US',
theme: 'light',
layout_mode: 'normal',
default_view_mode: 'rendered',
ai_chat_enabled: 'true',
translation_enabled: 'false',
summary_enabled: 'false',
full_text_fetch_enabled: 'false',
update_check_enabled: 'false',
};
const feed = {
id: 1,
title: 'Chat Feed',
url: 'https://example.com/chat.xml',
category: '',
article_view_mode: 'global',
};
const article = {
id: 1,
feed_id: 1,
feed_title: feed.title,
title: 'Chat article',
url: 'https://example.com/chat/article',
published_at: '2026-08-25T00:00:00Z',
is_read: false,
is_favorite: false,
is_hidden: false,
is_read_later: false,
};
let nextSessionID = 1;
const sessions: Array<Record<string, unknown>> = [];
const messages = new Map<number, Array<Record<string, unknown>>>();
const timestamp = () => new Date().toISOString();

cy.intercept('/api/**', { statusCode: 200, body: {} });
cy.intercept('GET', '/api/settings', { statusCode: 200, body: settingsState });
cy.intercept('GET', '/api/feeds', { statusCode: 200, body: [feed] }).as('chatFeeds');
cy.intercept('GET', '/api/tags', { statusCode: 200, body: [] });
cy.intercept('GET', '/api/saved-filters', { statusCode: 200, body: [] });
cy.intercept(
{ method: 'GET', pathname: '/api/articles' },
{ statusCode: 200, body: [article] }
).as('chatArticles');
cy.intercept('GET', '/api/articles/unread-counts', { statusCode: 200, body: {} });
cy.intercept('GET', '/api/articles/filter-counts', { statusCode: 200, body: {} });
cy.intercept('GET', '/api/progress', {
statusCode: 200,
body: { is_running: true, pool_task_count: 0, queue_task_count: 0 },
});
cy.intercept('GET', '/api/articles/content*', {
statusCode: 200,
body: { content: '<p>Article context for AI chat</p>', cached: true },
}).as('chatArticleContent');
cy.intercept('POST', '/api/articles/read*', { statusCode: 200, body: { success: true } });
cy.intercept('GET', '/api/ai/chat/sessions*', (req) => {
req.reply({ statusCode: 200, body: sessions });
}).as('chatSessions');
cy.intercept('GET', '/api/ai/chat/messages*', (req) => {
req.reply({ statusCode: 200, body: messages.get(Number(req.query.session_id)) || [] });
}).as('chatMessages');
cy.intercept('POST', '/api/ai/chat/session/create', (req) => {
const id = nextSessionID++;
const session = {
id,
article_id: 1,
title: req.body.title,
created_at: timestamp(),
updated_at: timestamp(),
message_count: 0,
};
sessions.unshift(session);
messages.set(id, []);
req.reply({ statusCode: 200, body: session });
}).as('createChatSession');
cy.intercept('POST', '/api/ai-chat', (req) => {
const lastMessage = req.body.messages.at(-1)?.content || '';
let sessionID = Number(req.body.session_id || 0);
if (!sessionID) {
sessionID = nextSessionID++;
sessions.unshift({
id: sessionID,
article_id: 1,
title: lastMessage.slice(0, 60),
created_at: timestamp(),
updated_at: timestamp(),
message_count: 0,
});
}
const stored = messages.get(sessionID) || [];
stored.push({
id: stored.length + 1,
role: 'user',
content: lastMessage,
created_at: timestamp(),
});
messages.set(sessionID, stored);

if (lastMessage === 'trigger failure') {
req.reply({
statusCode: 500,
body: { error: 'Failed to get response from AI. Please try again.', session_id: sessionID },
});
return;
}

stored.push({
id: stored.length + 1,
role: 'assistant',
content: 'Persisted answer',
created_at: timestamp(),
});
const session = sessions.find((item) => item.id === sessionID);
if (session) session.message_count = stored.length;
req.reply({
statusCode: 200,
body: { response: 'Persisted answer', session_id: sessionID, history_saved: true },
});
}).as('aiChat');

cy.reload();
cy.wait('@chatFeeds');
cy.wait('@chatArticles');
cy.get('[data-article-id="1"]').click();
cy.wait('@chatArticleContent');
cy.get('button[title="AI Chat"]').click();
cy.wait('@chatSessions');

cy.get('input[placeholder="Type a message..."]').type('First question{enter}');
cy.wait('@aiChat');
cy.contains('.chat-panel', 'Persisted answer').should('be.visible');

cy.get('[data-testid="chat-new-session"]').click();
cy.wait('@createChatSession');
cy.wait('@chatMessages');
cy.contains('.chat-panel', 'Persisted answer').should('not.exist');
cy.get('[data-testid="chat-session-switcher"]').click();
cy.get('.chat-panel [data-session-id="1"]').click();
cy.wait('@chatMessages');
cy.contains('.chat-panel', 'Persisted answer').should('be.visible');

cy.get('input[placeholder="Type a message..."]').type('trigger failure{enter}');
cy.wait('@aiChat');
cy.wait('@chatMessages');
cy.contains('.chat-panel', 'trigger failure').should('be.visible');
cy.contains('Failed to get response from AI. Please try again.').should('be.visible');
});

});
Loading