Skip to content

feat(gui): add DeepSeek GUI with layout fixes and CI packaging#3349

Closed
victorhuang868 wants to merge 1 commit into
Hmbown:mainfrom
victorhuang868:feat/deepseek-gui-layout-ci
Closed

feat(gui): add DeepSeek GUI with layout fixes and CI packaging#3349
victorhuang868 wants to merge 1 commit into
Hmbown:mainfrom
victorhuang868:feat/deepseek-gui-layout-ci

Conversation

@victorhuang868

Copy link
Copy Markdown

Summary

  • Add Tauri Deepseek-GUI desktop app (161 files)
  • Fix Composer clickability and three-column layout issues
  • Add GitHub Actions workflow for Windows NSIS + macOS DMG with deepseek-tui sidecar

Test plan

  • DeepSeek GUI Build workflow passes on macOS and Windows
  • Download deepseek-gui-macos-arm64 artifact and verify DMG
  • Download deepseek-gui-windows-x64 artifact and verify NSIS installer

Made with Cursor

Introduce the Tauri Deepseek-GUI app with Composer clickability and three-column layout fixes, plus GitHub Actions to build Windows NSIS and macOS DMG installers with the deepseek-tui sidecar.

Co-authored-by: Cursor <cursoragent@cursor.com>
@victorhuang868 victorhuang868 requested a review from Hmbown as a code owner June 20, 2026 06:21
@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review. (161 files found, 100 file limit)

@github-actions

Copy link
Copy Markdown

Thanks @victorhuang868 for taking the time to contribute.

This repository is observing a maintainer-managed PR intake gate in dry-run mode, so this pull request is staying open. This note helps maintainers prepare the allowlist before any enforcement is considered.

Please read CONTRIBUTING.md for the expected contribution shape. A maintainer can grant recurring PR access by commenting /lgtm on a pull request.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces DeepSeek GUI, a desktop graphical interface for the DeepSeek CLI/TUI built on Tauri, React, and TypeScript. It features an IDE-like three-column layout, multi-session chat, a CodeMirror-based editor, xterm.js terminal integration, and various configuration panels. The review feedback highlights several critical issues that need to be addressed: a race condition in the Rust LSP bridge that can spawn duplicate server processes, compatibility issues in the SSE event parser when handling CRLF line endings, and a missing workspace directory validation when restarting the backend. Additionally, there is a widespread error-handling issue across multiple UI components where Tauri command rejections (returned as strings) are treated as standard Error objects, which would display as 'undefined' in the UI; using the formatInvokeError helper is recommended to resolve this.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +49 to +58
buffer += decoder.decode(value, { stream: true });

// 按 SSE 规范以空行分隔事件块
let idx: number;
while ((idx = buffer.indexOf("\n\n")) !== -1) {
const rawBlock = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const evt = parseSseBlock(rawBlock);
if (evt) {
lastSeq = Math.max(lastSeq, evt.seq);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The SSE protocol allows both \n\n and \r\n\r\n as event separators. If the backend server uses standard CRLF (\r\n) line endings, buffer.indexOf("\n\n") will return -1 because of the \r character between the two \ns, preventing any events from being parsed. Searching for both separators ensures compatibility across different platforms and server configurations.

          let idx: number;
          while (true) {
            let sep = "\n\n";
            idx = buffer.indexOf("\n\n");
            if (idx === -1) {
              idx = buffer.indexOf("\r\n\r\n");
              sep = "\r\n\r\n";
            }
            if (idx === -1) break;

            const rawBlock = buffer.slice(0, idx);
            buffer = buffer.slice(idx + sep.length);
            const evt = parseSseBlock(rawBlock);
            if (evt) {
              lastSeq = Math.max(lastSeq, evt.seq);
              onEvent(evt);
            }
          }

Comment on lines +80 to +128
{
let guard = self.sessions.lock().map_err(|e| e.to_string())?;
if guard.contains_key(&session_id) {
return Ok(LspSessionInfo {
session_id: session_id.clone(),
language_id: lang.language_id().to_string(),
root_uri: uri_from_path(&workspace_path),
server_command: command.to_string(),
});
}
}

let mut child = spawn_lsp_process(command, args, &workspace_path)?;
let stdin = child
.stdin
.take()
.ok_or_else(|| format!("LSP `{command}` 无 stdin"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| format!("LSP `{command}` 无 stdout"))?;

let (tx_outbound, mut rx_outbound) = mpsc::unbounded_channel::<String>();
let app_reader = app.clone();
let session_id_reader = session_id.clone();

// 写 stdin:前端 JSON → Content-Length 帧
tauri::async_runtime::spawn(async move {
let mut stdin = stdin;
while let Some(json) = rx_outbound.recv().await {
if write_framed(&mut stdin, json.as_bytes()).await.is_err() {
break;
}
}
});

// 读 stdout:Content-Length 帧 → 前端 JSON 事件
tauri::async_runtime::spawn(async move {
read_stdout_loop(stdout, app_reader, session_id_reader).await;
});

let session = LspSession {
tx_outbound,
_child: child,
};
self.sessions
.lock()
.map_err(|e| e.to_string())?
.insert(session_id.clone(), session);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a race condition here. Since ensure_session is an async fn but contains no .await points between checking the sessions map and inserting the newly spawned session, multiple concurrent requests for the same session_id can check the map simultaneously, find it empty, and spawn duplicate LSP server processes. Keeping the lock held for the entire duration of the session setup avoids this race condition.

        let mut guard = self.sessions.lock().map_err(|e| e.to_string())?;
        if guard.contains_key(&session_id) {
            return Ok(LspSessionInfo {
                session_id: session_id.clone(),
                language_id: lang.language_id().to_string(),
                root_uri: uri_from_path(&workspace_path),
                server_command: command.to_string(),
            });
        }

        let mut child = spawn_lsp_process(command, args, &workspace_path)?;
        let stdin = child
            .stdin
            .take()
            .ok_or_else(|| format!("LSP `{command}` 无 stdin"))?;
        let stdout = child
            .stdout
            .take()
            .ok_or_else(|| format!("LSP `{command}` 无 stdout"))?;

        let (tx_outbound, mut rx_outbound) = mpsc::unbounded_channel::<String>();
        let app_reader = app.clone();
        let session_id_reader = session_id.clone();

        // 写 stdin:前端 JSON → Content-Length 帧
        tauri::async_runtime::spawn(async move {
            let mut stdin = stdin;
            while let Some(json) = rx_outbound.recv().await {
                if write_framed(&mut stdin, json.as_bytes()).await.is_err() {
                    break;
                }
            }
        });

        // 读 stdout:Content-Length 帧 → 前端 JSON 事件
        tauri::async_runtime::spawn(async move {
            read_stdout_loop(stdout, app_reader, session_id_reader).await;
        });

        let session = LspSession {
            tx_outbound,
            _child: child,
        };
        guard.insert(session_id.clone(), session);

setMsg(null);
} catch (e) {
setMsg((e as Error).message);
} finally {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Tauri command rejections are typically returned as strings rather than standard Error objects. Using (e as Error).message will result in undefined being displayed in the UI. Use formatInvokeError to safely extract the error message.

Suggested change
} finally {
setMsg(formatInvokeError(e));

} catch (e) {
setMsg((e as Error).message);
} finally {
setLoading(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Tauri command rejections are typically returned as strings rather than standard Error objects. Using (e as Error).message will result in undefined being displayed in the UI. Use formatInvokeError to safely extract the error message.

Suggested change
setLoading(false);
setMsg(formatInvokeError(e));

import CodeMirror from "@uiw/react-codemirror";
import { vscodeDark } from "@uiw/codemirror-theme-vscode";
import { EditorView } from "@codemirror/view";
import { readFile, writeFile } from "../api/tauri";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import formatInvokeError to safely handle Tauri command rejections, which are typically returned as strings rather than standard Error objects.

Suggested change
import { readFile, writeFile } from "../api/tauri";
import { readFile, writeFile, formatInvokeError } from "../api/tauri";

await restartBackend();
setHooks(nextHooks);
setJsonText(JSON.stringify(nextHooks, null, 2));
setMsg(zh ? "已保存" : "Saved");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Tauri command rejections are typically returned as strings rather than standard Error objects. Using (e as Error).message will result in undefined being displayed in the UI. Use formatInvokeError to safely extract the error message.

Suggested change
setMsg(zh ? "已保存" : "Saved");
setMsg(formatInvokeError(e));

await addTrust(workspace, p);
await refresh();
setNewPath("");
setMsg(zh ? "已新增信任路径" : "Trusted path added");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Tauri command rejections are typically returned as strings rather than standard Error objects. Using (e as Error).message will result in undefined being displayed in the UI. Use formatInvokeError to safely extract the error message.

Suggested change
setMsg(zh ? "已新增信任路径" : "Trusted path added");
setMsg(formatInvokeError(e));

await refresh();
setMsg(zh ? "已移除" : "Removed");
} catch (e) {
setMsg((e as Error).message);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Tauri command rejections are typically returned as strings rather than standard Error objects. Using (e as Error).message will result in undefined being displayed in the UI. Use formatInvokeError to safely extract the error message.

Suggested change
setMsg((e as Error).message);
setMsg(formatInvokeError(e));

Comment on lines +7 to +13
import type { Locale } from "../i18n";
import {
isTauri,
ptyClose,
ptyResize,
ptySpawn,
ptyWrite,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import formatInvokeError to safely handle Tauri command rejections, which are typically returned as strings rather than standard Error objects.

Suggested change
import type { Locale } from "../i18n";
import {
isTauri,
ptyClose,
ptyResize,
ptySpawn,
ptyWrite,
import {
isTauri,
ptyClose,
ptyResize,
ptySpawn,
ptyWrite,
formatInvokeError,
} from "../api/tauri";

});

term.onData((data) => {
const pid = ptyIdRef.current;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Tauri command rejections are typically returned as strings rather than standard Error objects. Using (e as Error).message will result in undefined being displayed in the UI. Use formatInvokeError to safely extract the error message.

Suggested change
const pid = ptyIdRef.current;
setErr(formatInvokeError(e));

@victorhuang868

Copy link
Copy Markdown
Author

Closing: GUI moved to standalone repo https://github.com/victorhuang868/Deepseek-GUI (separate from TUI CLI).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant