From eb8f7d0eb186c203c328c21ae884c173ac32e7d5 Mon Sep 17 00:00:00 2001 From: Akey Zhang Date: Thu, 11 Jun 2026 20:24:09 +0800 Subject: [PATCH 1/8] style: bind popover appearance to NSApp.effectiveAppearance to fix arrow color in dark mode --- Sources/Signal/AppDelegate.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Signal/AppDelegate.swift b/Sources/Signal/AppDelegate.swift index 3429349..99107f2 100644 --- a/Sources/Signal/AppDelegate.swift +++ b/Sources/Signal/AppDelegate.swift @@ -55,6 +55,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc private func appearanceDidChange() { applyIcon() + popover?.appearance = NSApp.effectiveAppearance } // MARK: Popover Setup @@ -62,6 +63,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func setupPopover() { popover = NSPopover() popover.behavior = .transient + popover.appearance = NSApp.effectiveAppearance let hostingController = NSHostingController(rootView: MainView(viewModel: viewModel)) hostingController.preferredContentSize = NSSize(width: 350, height: 480) From 12fa8fab0e62e2c3796ba40a32a39cd9b9c2ba5b Mon Sep 17 00:00:00 2001 From: Akey Zhang Date: Wed, 17 Jun 2026 08:31:09 +0800 Subject: [PATCH 2/8] feat: add chat detail --- .../Signal/Models/ClaudeHistoryModel.swift | 143 ++++++++++++ Sources/Signal/Views/ClaudeDetailView.swift | 218 ++++++++++++++++-- 2 files changed, 348 insertions(+), 13 deletions(-) diff --git a/Sources/Signal/Models/ClaudeHistoryModel.swift b/Sources/Signal/Models/ClaudeHistoryModel.swift index 8768bde..c7a07f1 100644 --- a/Sources/Signal/Models/ClaudeHistoryModel.swift +++ b/Sources/Signal/Models/ClaudeHistoryModel.swift @@ -17,7 +17,150 @@ struct ClaudeSession: Identifiable, Hashable { let prompts: [HistoryLine] } +struct AssistantResponse: Codable, Hashable { + let thinking: String? + let text: String +} + +struct SessionJSONLLine: Codable { + let type: String + let timestamp: String? + let message: SessionMessage? +} + +struct SessionMessage: Codable { + let id: String? + let role: String? + let content: SessionContent? +} + +enum SessionContent: Codable { + case string(String) + case array([SessionContentBlock]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let str = try? container.decode(String.self) { + self = .string(str) + } else if let arr = try? container.decode([SessionContentBlock].self) { + self = .array(arr) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid content format") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let str): + try container.encode(str) + case .array(let arr): + try container.encode(arr) + } + } +} + +struct SessionContentBlock: Codable { + let type: String + let text: String? + let thinking: String? +} + class ClaudeHistoryLoader { + static func loadReplies(projectPath: String, sessionId: String, completion: @escaping ([Int64: AssistantResponse]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let home = FileManager.default.homeDirectoryForCurrentUser + let escapedProject = projectPath + .replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: ".", with: "-") + let sessionURL = home.appendingPathComponent(".claude/projects/\(escapedProject)/\(sessionId).jsonl") + + guard FileManager.default.fileExists(atPath: sessionURL.path) else { + DispatchQueue.main.async { completion([:]) } + return + } + + do { + let content = try String(contentsOf: sessionURL, encoding: .utf8) + let lines = content.components(separatedBy: .newlines) + + let decoder = JSONDecoder() + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + let isoFormatterFallback = ISO8601DateFormatter() + isoFormatterFallback.formatOptions = [.withInternetDateTime] + + var currentPromptTimestamp: Int64? = nil + var accumulatedText: [String] = [] + var accumulatedThinking: [String] = [] + var replies: [Int64: AssistantResponse] = [:] + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { continue } + + guard let data = trimmed.data(using: .utf8), + let parsedLine = try? decoder.decode(SessionJSONLLine.self, from: data) else { + continue + } + + if parsedLine.type == "user" { + if let prevTimestamp = currentPromptTimestamp { + let text = accumulatedText.joined(separator: "\n") + let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") + replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) + } + + accumulatedText = [] + accumulatedThinking = [] + + if let timestampStr = parsedLine.timestamp { + let parsedDate = isoFormatter.date(from: timestampStr) ?? isoFormatterFallback.date(from: timestampStr) + if let date = parsedDate { + currentPromptTimestamp = Int64(date.timeIntervalSince1970 * 1000) + } else { + currentPromptTimestamp = nil + } + } else { + currentPromptTimestamp = nil + } + } else if parsedLine.type == "assistant", let message = parsedLine.message { + if let contentBlocks = message.content { + switch contentBlocks { + case .string(let str): + accumulatedText.append(str) + case .array(let blocks): + for block in blocks { + if block.type == "text", let text = block.text { + accumulatedText.append(text) + } else if block.type == "thinking", let thinking = block.thinking { + accumulatedThinking.append(thinking) + } + } + } + } + } + } + + if let prevTimestamp = currentPromptTimestamp { + let text = accumulatedText.joined(separator: "\n") + let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") + replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) + } + + DispatchQueue.main.async { + completion(replies) + } + } catch { + print("Error loading Claude replies: \(error)") + DispatchQueue.main.async { + completion([:]) + } + } + } + } + static func loadHistory(completion: @escaping ([ClaudeSession]) -> Void) { DispatchQueue.global(qos: .userInitiated).async { let home = FileManager.default.homeDirectoryForCurrentUser diff --git a/Sources/Signal/Views/ClaudeDetailView.swift b/Sources/Signal/Views/ClaudeDetailView.swift index c209682..5126be1 100644 --- a/Sources/Signal/Views/ClaudeDetailView.swift +++ b/Sources/Signal/Views/ClaudeDetailView.swift @@ -4,6 +4,10 @@ struct ClaudeDetailView: View { let session: ClaudeSession @Environment(\.dismiss) private var dismiss + @State private var replies: [Int64: AssistantResponse] = [:] + @State private var expandedPromptIds: Set = [] + @State private var isLoadingReplies: Bool = true + var body: some View { VStack(spacing: 0) { // Custom Navigation Header @@ -26,7 +30,7 @@ struct ClaudeDetailView: View { Spacer() - Text(NSLocalizedString("User Prompts", comment: "")) + Text(NSLocalizedString("Conversation Details", comment: "")) .font(.system(size: 13, weight: .bold)) Spacer() @@ -77,9 +81,24 @@ struct ClaudeDetailView: View { .background(Color(NSColor.controlBackgroundColor).opacity(0.3)) .cornerRadius(6) - // Chronological Prompts List - ForEach(session.prompts) { prompt in - PromptBubbleView(prompt: prompt) + // Chronological Prompts List (Time reversed, newest first) + let sortedPrompts = session.prompts.sorted(by: { $0.timestamp > $1.timestamp }) + ForEach(sortedPrompts) { prompt in + PromptBubbleView( + prompt: prompt, + isExpanded: expandedPromptIds.contains(prompt.id), + isLoadingReplies: isLoadingReplies, + reply: findReply(for: prompt.timestamp), + onTap: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) { + if expandedPromptIds.contains(prompt.id) { + expandedPromptIds.remove(prompt.id) + } else { + expandedPromptIds.insert(prompt.id) + } + } + } + ) } } .padding(.all, 12) @@ -87,31 +106,204 @@ struct ClaudeDetailView: View { } .navigationBarBackButtonHidden(true) .background(Color(NSColor.controlBackgroundColor)) + .onAppear { + isLoadingReplies = true + ClaudeHistoryLoader.loadReplies(projectPath: session.projectPath, sessionId: session.sessionId) { loadedReplies in + self.replies = loadedReplies + self.isLoadingReplies = false + } + } + } + + private func findReply(for promptTimestamp: Int64) -> AssistantResponse? { + let maxDiff: Int64 = 5000 // 5 seconds + var bestKey: Int64? = nil + var minDiff = maxDiff + + for key in replies.keys { + let diff = abs(key - promptTimestamp) + if diff < minDiff { + minDiff = diff + bestKey = key + } + } + + if let key = bestKey { + return replies[key] + } + return nil } } struct PromptBubbleView: View { let prompt: HistoryLine + let isExpanded: Bool + let isLoadingReplies: Bool + let reply: AssistantResponse? + let onTap: () -> Void + + @State private var isPromptHovered = false + @State private var showThinkingExpanded = false + @State private var isCopySuccess = false var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 6) { // Prompt Card VStack(alignment: .leading, spacing: 0) { - Text(prompt.display) - .font(.system(size: 12)) - .foregroundColor(.primary) - .lineSpacing(4) - .textSelection(.enabled) - .multilineTextAlignment(.leading) + HStack(alignment: .top) { + Text(prompt.display) + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineSpacing(4) + .textSelection(.enabled) + .multilineTextAlignment(.leading) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(.secondary.opacity(0.5)) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .padding(.top, 3) + } } .padding(.all, 12) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.accentColor.opacity(0.08)) + .background(Color.accentColor.opacity(isPromptHovered ? 0.12 : 0.08)) .overlay( RoundedRectangle(cornerRadius: 10) - .stroke(Color.accentColor.opacity(0.15), lineWidth: 1) + .stroke(Color.accentColor.opacity(0.2), lineWidth: 1) ) .cornerRadius(10) + .onHover { hovering in + isPromptHovered = hovering + } + .onTapGesture { + onTap() + } + + // Expanded Reply Content + if isExpanded { + VStack(alignment: .leading, spacing: 8) { + if isLoadingReplies { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(NSLocalizedString("Loading feedback...", comment: "")) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .padding(.vertical, 6) + .padding(.leading, 12) + } else if let reply = reply { + // Thinking Process (if available) + if let thinking = reply.thinking, !thinking.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + VStack(alignment: .leading, spacing: 4) { + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + showThinkingExpanded.toggle() + } + }) { + HStack(spacing: 4) { + Image(systemName: "brain.head.profile") + .font(.system(size: 10)) + Text(NSLocalizedString("Thinking Process", comment: "")) + .font(.system(size: 10, weight: .bold)) + Image(systemName: showThinkingExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 7)) + } + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + + if showThinkingExpanded { + Text(thinking) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary.opacity(0.8)) + .padding(.all, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.03)) + .cornerRadius(6) + .textSelection(.enabled) + } + } + .padding(.horizontal, 4) + } + + // Assistant Reply Bubble + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "sparkles") + .font(.system(size: 11)) + .foregroundColor(.accentColor) + Text(NSLocalizedString("Assistant Feedback", comment: "")) + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.accentColor) + Spacer() + + // Copy Button + Button(action: { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(reply.text, forType: .string) + withAnimation { + isCopySuccess = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { + isCopySuccess = false + } + } + }) { + HStack(spacing: 3) { + Image(systemName: isCopySuccess ? "checkmark.circle.fill" : "doc.on.doc") + .font(.system(size: 9)) + Text(isCopySuccess ? NSLocalizedString("Copied", comment: "") : NSLocalizedString("Copy", comment: "")) + .font(.system(size: 9, weight: .semibold)) + } + .foregroundColor(isCopySuccess ? .green : .secondary) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.primary.opacity(0.04)) + .cornerRadius(4) + } + .buttonStyle(.plain) + } + + Text(reply.text) + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineSpacing(4) + .textSelection(.enabled) + .multilineTextAlignment(.leading) + } + .padding(.all, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(NSColor.controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + .cornerRadius(10) + } else { + // No response found + HStack(spacing: 6) { + Image(systemName: "info.circle") + .font(.system(size: 11)) + Text(NSLocalizedString("No reply recorded for this prompt.", comment: "")) + .font(.system(size: 11, weight: .medium)) + } + .foregroundColor(.secondary) + .padding(.all, 10) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color.primary.opacity(0.03)) + .cornerRadius(8) + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + .padding(.leading, 8) + .padding(.trailing, 4) + } // Timestamp below bubble HStack { From dd41b344f76e9d745947f0077878e47e760620bc Mon Sep 17 00:00:00 2001 From: Akey Zhang Date: Wed, 17 Jun 2026 08:40:00 +0800 Subject: [PATCH 3/8] feat(history): support interactive replies and multi-tool history loaders - Implemented direct read-only SQLite database wrapper in Swift. - Added load and parse functionality for Claude, OpenCode, Pi, and Trae. - Added segmented control tool switcher to history view. - Sorted conversations and prompts in reverse-chronological order. - Enabled inline dialogue expansions showing assistant replies and thinking logs. AI-Generated-By: Antigravity AI-Model: Gemini AI-Agent: N/A AI-Skill: N/A --- .../Signal/Models/ClaudeHistoryModel.swift | 491 +++++++++++++++++- Sources/Signal/Models/SQLiteDatabase.swift | 53 ++ Sources/Signal/ViewModels/AppViewModel.swift | 43 +- Sources/Signal/Views/ClaudeDetailView.swift | 32 +- Sources/Signal/Views/ClaudeHistoryView.swift | 16 + 5 files changed, 624 insertions(+), 11 deletions(-) create mode 100644 Sources/Signal/Models/SQLiteDatabase.swift diff --git a/Sources/Signal/Models/ClaudeHistoryModel.swift b/Sources/Signal/Models/ClaudeHistoryModel.swift index c7a07f1..815b71f 100644 --- a/Sources/Signal/Models/ClaudeHistoryModel.swift +++ b/Sources/Signal/Models/ClaudeHistoryModel.swift @@ -8,8 +8,18 @@ struct HistoryLine: Codable, Identifiable, Hashable { let sessionId: String } -struct ClaudeSession: Identifiable, Hashable { +enum ToolType: String, Codable, CaseIterable, Identifiable { + case claude = "Claude" + case openCode = "OpenCode" + case pi = "Pi" + case trae = "Trae" + + var id: String { self.rawValue } +} + +struct HistorySession: Identifiable, Hashable { var id: String { sessionId } + let toolType: ToolType let sessionId: String let projectPath: String let projectName: String @@ -17,6 +27,8 @@ struct ClaudeSession: Identifiable, Hashable { let prompts: [HistoryLine] } +typealias ClaudeSession = HistorySession + struct AssistantResponse: Codable, Hashable { let thinking: String? let text: String @@ -67,6 +79,476 @@ struct SessionContentBlock: Codable { } class ClaudeHistoryLoader { + static func loadPiHistory(completion: @escaping ([HistorySession]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let home = FileManager.default.homeDirectoryForCurrentUser + let sessionsURL = home.appendingPathComponent(".pi/agent/sessions") + + guard FileManager.default.fileExists(atPath: sessionsURL.path) else { + DispatchQueue.main.async { completion([]) } + return + } + + var sessions: [HistorySession] = [] + let fileManager = FileManager.default + let decoder = JSONDecoder() + + struct PiSessionStart: Codable { + let type: String + let id: String + let timestamp: String + let cwd: String + } + + struct PiLine: Codable { + let type: String + let message: PiMessage? + } + + struct PiMessage: Codable { + let role: String + let content: SessionContent? + let timestamp: Int64? + } + + if let projectDirs = try? fileManager.contentsOfDirectory(at: sessionsURL, includingPropertiesForKeys: nil) { + for projectDir in projectDirs { + var isDir: ObjCBool = false + if fileManager.fileExists(atPath: projectDir.path, isDirectory: &isDir), isDir.boolValue { + if let files = try? fileManager.contentsOfDirectory(at: projectDir, includingPropertiesForKeys: nil) { + for file in files where file.pathExtension == "jsonl" { + do { + let content = try String(contentsOf: file, encoding: .utf8) + let lines = content.components(separatedBy: .newlines) + + guard let firstLine = lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), + !firstLine.isEmpty, + let firstData = firstLine.data(using: .utf8), + let sessionStart = try? decoder.decode(PiSessionStart.self, from: firstData) else { + continue + } + + let sessionId = sessionStart.id + let projectPath = sessionStart.cwd + let projectName = URL(fileURLWithPath: projectPath).lastPathComponent + + var prompts: [HistoryLine] = [] + + for line in lines.dropFirst() { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { continue } + + guard let lineData = trimmed.data(using: .utf8), + let parsedLine = try? decoder.decode(PiLine.self, from: lineData) else { + continue + } + + if parsedLine.type == "message", let msg = parsedLine.message, msg.role == "user" { + var text = "" + if let content = msg.content { + switch content { + case .string(let str): + text = str + case .array(let blocks): + text = blocks.compactMap { $0.text }.joined(separator: "\n") + } + } + + let timestamp: Int64 + if let ts = msg.timestamp { + timestamp = ts + } else { + let attrs = try? fileManager.attributesOfItem(atPath: file.path) + let creationDate = attrs?[.creationDate] as? Date ?? Date() + timestamp = Int64(creationDate.timeIntervalSince1970 * 1000) + } + + let historyLine = HistoryLine( + display: text, + timestamp: timestamp, + project: projectPath, + sessionId: sessionId + ) + prompts.append(historyLine) + } + } + + if !prompts.isEmpty { + let lastTimestamp = prompts.last?.timestamp ?? Int64(Date().timeIntervalSince1970 * 1000) + let session = HistorySession( + toolType: .pi, + sessionId: sessionId, + projectPath: projectPath, + projectName: projectName, + lastTimestamp: lastTimestamp, + prompts: prompts + ) + sessions.append(session) + } + } catch { + // Ignore individual file parse errors + } + } + } + } + } + } + + sessions.sort(by: { $0.lastTimestamp > $1.lastTimestamp }) + DispatchQueue.main.async { + completion(sessions) + } + } + } + + static func loadPiReplies(projectPath: String, sessionId: String, completion: @escaping ([Int64: AssistantResponse]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let home = FileManager.default.homeDirectoryForCurrentUser + let fm = FileManager.default + let escapedProject = projectPath + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + .replacingOccurrences(of: "/", with: "-") + let projectDir = home.appendingPathComponent(".pi/agent/sessions/--\(escapedProject)--") + + guard fm.fileExists(atPath: projectDir.path) else { + DispatchQueue.main.async { completion([:]) } + return + } + + var replies: [Int64: AssistantResponse] = [:] + + if let files = try? fm.contentsOfDirectory(at: projectDir, includingPropertiesForKeys: nil) { + for file in files where file.pathExtension == "jsonl" && file.lastPathComponent.contains(sessionId) { + do { + let content = try String(contentsOf: file, encoding: .utf8) + let lines = content.components(separatedBy: .newlines) + + let decoder = JSONDecoder() + + struct PiLine: Codable { + let type: String + let message: PiMessage? + } + + struct PiMessage: Codable { + let role: String + let content: SessionContent? + let timestamp: Int64? + } + + var currentPromptTimestamp: Int64? = nil + var accumulatedText: [String] = [] + var accumulatedThinking: [String] = [] + + for line in lines.dropFirst() { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { continue } + + guard let lineData = trimmed.data(using: .utf8), + let parsedLine = try? decoder.decode(PiLine.self, from: lineData) else { + continue + } + + if parsedLine.type == "message", let msg = parsedLine.message { + if msg.role == "user" { + if let prevTimestamp = currentPromptTimestamp { + let text = accumulatedText.joined(separator: "\n") + let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") + replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) + } + + accumulatedText = [] + accumulatedThinking = [] + + if let ts = msg.timestamp { + currentPromptTimestamp = ts + } else { + currentPromptTimestamp = nil + } + } else if msg.role == "assistant" { + if let content = msg.content { + switch content { + case .string(let str): + accumulatedText.append(str) + case .array(let blocks): + for block in blocks { + if block.type == "text", let text = block.text { + accumulatedText.append(text) + } else if block.type == "thinking", let thinking = block.thinking { + accumulatedThinking.append(thinking) + } + } + } + } + } + } + } + + if let prevTimestamp = currentPromptTimestamp { + let text = accumulatedText.joined(separator: "\n") + let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") + replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) + } + } catch { + // ignore + } + break + } + } + + DispatchQueue.main.async { + completion(replies) + } + } + } + + static func loadOpenCodeHistory(completion: @escaping ([HistorySession]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let home = FileManager.default.homeDirectoryForCurrentUser + let dbPath = home.appendingPathComponent(".local/share/opencode/opencode.db").path + + guard FileManager.default.fileExists(atPath: dbPath), + let db = SQLiteDatabase(path: dbPath) else { + DispatchQueue.main.async { completion([]) } + return + } + + var sessions: [HistorySession] = [] + + let sessionsQuery = "select id, title, directory, time_created from session order by time_created desc" + let sessionRows = db.query(sql: sessionsQuery) + + for row in sessionRows { + guard let id = row["id"], !id.isEmpty, + let directory = row["directory"], + let timeCreatedStr = row["time_created"], + let timeCreated = Int64(timeCreatedStr) else { + continue + } + + let projectName = URL(fileURLWithPath: directory).lastPathComponent + + let promptsQuery = """ + select part.id, part.data, part.time_created + from part + join message on part.message_id = message.id + where part.session_id = ? + and json_extract(message.data, '$.role') = 'user' + and json_extract(part.data, '$.type') = 'text' + order by part.time_created + """ + let promptRows = db.query(sql: promptsQuery, parameters: [id]) + + var prompts: [HistoryLine] = [] + for pRow in promptRows { + guard let partDataStr = pRow["data"], + let timeCreatedStr = pRow["time_created"], + let timeCreated = Int64(timeCreatedStr) else { + continue + } + + struct OpenCodePartData: Codable { + let text: String? + } + if let data = partDataStr.data(using: .utf8), + let partData = try? JSONDecoder().decode(OpenCodePartData.self, from: data), + let text = partData.text { + let line = HistoryLine( + display: text, + timestamp: timeCreated, + project: directory, + sessionId: id + ) + prompts.append(line) + } + } + + if !prompts.isEmpty { + let lastTimestamp = prompts.last?.timestamp ?? timeCreated + let session = HistorySession( + toolType: .openCode, + sessionId: id, + projectPath: directory, + projectName: projectName, + lastTimestamp: lastTimestamp, + prompts: prompts + ) + sessions.append(session) + } + } + + sessions.sort(by: { $0.lastTimestamp > $1.lastTimestamp }) + DispatchQueue.main.async { + completion(sessions) + } + } + } + + static func loadOpenCodeReplies(sessionId: String, completion: @escaping ([Int64: AssistantResponse]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let home = FileManager.default.homeDirectoryForCurrentUser + let dbPath = home.appendingPathComponent(".local/share/opencode/opencode.db").path + + guard FileManager.default.fileExists(atPath: dbPath), + let db = SQLiteDatabase(path: dbPath) else { + DispatchQueue.main.async { completion([:]) } + return + } + + var replies: [Int64: AssistantResponse] = [:] + + let querySql = """ + select message.id as msg_id, + json_extract(message.data, '$.role') as role, + json_extract(part.data, '$.type') as part_type, + json_extract(part.data, '$.text') as part_text, + part.time_created + from part + join message on part.message_id = message.id + where part.session_id = ? + and (part_type = 'text' or part_type = 'reasoning') + order by part.time_created + """ + let rows = db.query(sql: querySql, parameters: [sessionId]) + + var currentPromptTimestamp: Int64? = nil + var accumulatedText: [String] = [] + var accumulatedThinking: [String] = [] + + for row in rows { + guard let role = row["role"], + let partType = row["part_type"], + let partText = row["part_text"], + let timeCreatedStr = row["time_created"], + let timeCreated = Int64(timeCreatedStr) else { + continue + } + + if role == "user" { + if let prevTimestamp = currentPromptTimestamp { + let text = accumulatedText.joined(separator: "\n") + let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") + replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) + } + + accumulatedText = [] + accumulatedThinking = [] + currentPromptTimestamp = timeCreated + } else if role == "assistant" { + if partType == "text" { + accumulatedText.append(partText) + } else if partType == "reasoning" { + accumulatedThinking.append(partText) + } + } + } + + if let prevTimestamp = currentPromptTimestamp { + let text = accumulatedText.joined(separator: "\n") + let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") + replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) + } + + DispatchQueue.main.async { + completion(replies) + } + } + } + + static func loadTraeHistory(completion: @escaping ([HistorySession]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let home = FileManager.default.homeDirectoryForCurrentUser + let base = home.appendingPathComponent("Library/Application Support/Trae/User/workspaceStorage") + + guard FileManager.default.fileExists(atPath: base.path) else { + DispatchQueue.main.async { completion([]) } + return + } + + var sessions: [HistorySession] = [] + let fm = FileManager.default + let decoder = JSONDecoder() + + struct TraeInputHistoryEntry: Codable { + let inputText: String + } + + if let subdirs = try? fm.contentsOfDirectory(at: base, includingPropertiesForKeys: nil) { + for dir in subdirs { + let dbPath = dir.appendingPathComponent("state.vscdb").path + let workspaceJsonPath = dir.appendingPathComponent("workspace.json").path + + guard fm.fileExists(atPath: dbPath) && fm.fileExists(atPath: workspaceJsonPath) else { + continue + } + + var projectPath = "" + if let wsData = try? Data(contentsOf: URL(fileURLWithPath: workspaceJsonPath)), + let json = try? JSONSerialization.jsonObject(with: wsData) as? [String: Any] { + if let folder = json["folder"] as? String { + if let url = URL(string: folder) { + projectPath = url.path + } + } else if let configuration = json["configuration"] as? String { + if let url = URL(string: configuration) { + projectPath = url.path + } + } + } + + if projectPath.isEmpty { continue } + + let projectName = URL(fileURLWithPath: projectPath).lastPathComponent + let sessionId = dir.lastPathComponent + + guard let db = SQLiteDatabase(path: dbPath) else { continue } + let rows = db.query(sql: "select value from ItemTable where key = 'icube-ai-agent-storage-input-history'") + + if let row = rows.first, let value = row["value"], + let data = value.data(using: .utf8), + let entries = try? decoder.decode([TraeInputHistoryEntry].self, from: data) { + + var prompts: [HistoryLine] = [] + let attrs = try? fm.attributesOfItem(atPath: dbPath) + let modDate = attrs?[.modificationDate] as? Date ?? Date() + var timestamp = Int64(modDate.timeIntervalSince1970 * 1000) + + for entry in entries { + let cleanText = entry.inputText.trimmingCharacters(in: .whitespacesAndNewlines) + if cleanText.isEmpty { continue } + + let line = HistoryLine( + display: cleanText, + timestamp: timestamp, + project: projectPath, + sessionId: sessionId + ) + prompts.append(line) + timestamp -= 1000 + } + + if !prompts.isEmpty { + let session = HistorySession( + toolType: .trae, + sessionId: sessionId, + projectPath: projectPath, + projectName: projectName, + lastTimestamp: prompts.first?.timestamp ?? Int64(modDate.timeIntervalSince1970 * 1000), + prompts: prompts + ) + sessions.append(session) + } + } + } + } + + sessions.sort(by: { $0.lastTimestamp > $1.lastTimestamp }) + DispatchQueue.main.async { + completion(sessions) + } + } + } + static func loadReplies(projectPath: String, sessionId: String, completion: @escaping ([Int64: AssistantResponse]) -> Void) { DispatchQueue.global(qos: .userInitiated).async { let home = FileManager.default.homeDirectoryForCurrentUser @@ -161,7 +643,7 @@ class ClaudeHistoryLoader { } } - static func loadHistory(completion: @escaping ([ClaudeSession]) -> Void) { + static func loadHistory(completion: @escaping ([HistorySession]) -> Void) { DispatchQueue.global(qos: .userInitiated).async { let home = FileManager.default.homeDirectoryForCurrentUser let historyURL = home.appendingPathComponent(".claude/history.jsonl") @@ -200,14 +682,15 @@ class ClaudeHistoryLoader { let grouped = Dictionary(grouping: allLines, by: { $0.sessionId }) - var sessions: [ClaudeSession] = [] + var sessions: [HistorySession] = [] for (sessionId, lines) in grouped { let sortedLines = lines.sorted(by: { $0.timestamp < $1.timestamp }) guard let lastLine = sortedLines.last else { continue } let projectPath = lastLine.project let projectName = URL(fileURLWithPath: projectPath).lastPathComponent - let session = ClaudeSession( + let session = HistorySession( + toolType: .claude, sessionId: sessionId, projectPath: projectPath, projectName: projectName, diff --git a/Sources/Signal/Models/SQLiteDatabase.swift b/Sources/Signal/Models/SQLiteDatabase.swift new file mode 100644 index 0000000..13f27a6 --- /dev/null +++ b/Sources/Signal/Models/SQLiteDatabase.swift @@ -0,0 +1,53 @@ +import Foundation +import SQLite3 + +class SQLiteDatabase { + private var db: OpaquePointer? + + init?(path: String) { + if sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) != SQLITE_OK { + if let db = db { + sqlite3_close(db) + } + return nil + } + } + + deinit { + if let db = db { + sqlite3_close(db) + } + } + + func query(sql: String, parameters: [String] = []) -> [[String: String]] { + var stmt: OpaquePointer? + if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) != SQLITE_OK { + return [] + } + + for (index, param) in parameters.enumerated() { + sqlite3_bind_text(stmt, Int32(index + 1), (param as NSString).utf8String, -1, nil) + } + + var results: [[String: String]] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + var row: [String: String] = [:] + let columnCount = sqlite3_column_count(stmt) + for i in 0.. Date: Wed, 17 Jun 2026 08:51:02 +0800 Subject: [PATCH 4/8] style(history): rename history tab title to generic AI History Rename "Claude History" tab title and localized strings to "AI History" to reflect the newly added tools (Claude, OpenCode, Pi, Trae). AI-Generated-By: Antigravity AI-Model: Gemini AI-Agent: N/A AI-Skill: N/A --- Resources/en.lproj/Localizable.strings | 2 +- Resources/zh-Hans.lproj/Localizable.strings | 2 +- Sources/Signal/Views/MainView.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 89f1691..9d941dd 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -6,7 +6,7 @@ "Quit Signal" = "Quit Signal"; "Signal – Status Light" = "Signal – Status Light"; "Status Control" = "Status Control"; -"Claude History" = "Claude History"; +"AI History" = "AI History"; "Breathing Effect" = "Breathing Effect"; "No conversation history" = "No conversation history"; "Loading..." = "Loading..."; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index f183d46..e22db70 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -6,7 +6,7 @@ "Quit Signal" = "退出"; "Signal – Status Light" = "Signal – 状态指示灯"; "Status Control" = "状态控制"; -"Claude History" = "Claude 历史"; +"AI History" = "AI 历史"; "Breathing Effect" = "呼吸效果"; "No conversation history" = "暂无对话历史"; "Loading..." = "加载中..."; diff --git a/Sources/Signal/Views/MainView.swift b/Sources/Signal/Views/MainView.swift index fa96da9..d596021 100644 --- a/Sources/Signal/Views/MainView.swift +++ b/Sources/Signal/Views/MainView.swift @@ -12,7 +12,7 @@ struct MainView: View { selectedTab = 0 } - TabButton(title: NSLocalizedString("Claude History", comment: ""), icon: "bubble.left.and.bubble.right", isActive: selectedTab == 1) { + TabButton(title: NSLocalizedString("AI History", comment: ""), icon: "bubble.left.and.bubble.right", isActive: selectedTab == 1) { selectedTab = 1 } } From b04c7f6ff52627e4a107986043e3e508c2d14b57 Mon Sep 17 00:00:00 2001 From: Akey Zhang Date: Thu, 18 Jun 2026 10:20:36 +0800 Subject: [PATCH 5/8] style(trae): remove Trae-specific cloud disclaimer from detail view Remove the incorrect Trae-specific disclaimer claiming that full chat histories are stored in the cloud, replacing it with the standard empty reply message. AI-Generated-By: Antigravity AI-Model: Gemini AI-Agent: N/A AI-Skill: N/A --- Sources/Signal/Views/ClaudeDetailView.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Sources/Signal/Views/ClaudeDetailView.swift b/Sources/Signal/Views/ClaudeDetailView.swift index 8284f23..5ad1f06 100644 --- a/Sources/Signal/Views/ClaudeDetailView.swift +++ b/Sources/Signal/Views/ClaudeDetailView.swift @@ -308,14 +308,8 @@ struct PromptBubbleView: View { HStack(spacing: 6) { Image(systemName: "info.circle") .font(.system(size: 11)) - if toolType == .trae { - Text(NSLocalizedString("Trae stores full chat histories in the cloud. Only prompt input history is available locally.", comment: "")) - .font(.system(size: 11, weight: .medium)) - .multilineTextAlignment(.leading) - } else { - Text(NSLocalizedString("No reply recorded for this prompt.", comment: "")) - .font(.system(size: 11, weight: .medium)) - } + Text(NSLocalizedString("No reply recorded for this prompt.", comment: "")) + .font(.system(size: 11, weight: .medium)) } .foregroundColor(.secondary) .padding(.all, 10) From 1601452a6676d6db3f5ac5a2341c94d56633f705 Mon Sep 17 00:00:00 2001 From: Akey Zhang Date: Thu, 18 Jun 2026 14:25:41 +0800 Subject: [PATCH 6/8] fix(trae): support code-workspace parsing and fallbacks in history loader - Support parsing the 'workspace' key in workspace.json to parse .code-workspace files. - Strip '.code-workspace' suffix from projectName. - Add fallback to using directory name and directory path if the project path cannot be found. AI-Generated-By: Antigravity AI-Model: Gemini AI-Agent: N/A AI-Skill: N/A --- Sources/Signal/Models/ClaudeHistoryModel.swift | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Sources/Signal/Models/ClaudeHistoryModel.swift b/Sources/Signal/Models/ClaudeHistoryModel.swift index 815b71f..df9c33e 100644 --- a/Sources/Signal/Models/ClaudeHistoryModel.swift +++ b/Sources/Signal/Models/ClaudeHistoryModel.swift @@ -493,12 +493,25 @@ class ClaudeHistoryLoader { if let url = URL(string: configuration) { projectPath = url.path } + } else if let workspace = json["workspace"] as? String { + if let url = URL(string: workspace) { + projectPath = url.path + } } } - if projectPath.isEmpty { continue } + let projectName: String + if !projectPath.isEmpty { + var name = URL(fileURLWithPath: projectPath).lastPathComponent + if name.hasSuffix(".code-workspace") { + name = String(name.dropLast(".code-workspace".count)) + } + projectName = name + } else { + projectName = dir.lastPathComponent + projectPath = dir.path + } - let projectName = URL(fileURLWithPath: projectPath).lastPathComponent let sessionId = dir.lastPathComponent guard let db = SQLiteDatabase(path: dbPath) else { continue } From b1668b694f392a2bd066775366712a19fb79f571 Mon Sep 17 00:00:00 2001 From: Akey Zhang Date: Sun, 21 Jun 2026 10:54:06 +0800 Subject: [PATCH 7/8] feat(overlay): add screen-wide gaming-style vignette flash overlay Implement screen-wide red/yellow/green vignette and neon border glow overlays that flash or pulse upon status color changes. Includes configuration options (Off, 1 Flash, 3 Flashes, Continuous breathing pulse) directly within the preferences control panel and persisted to UserDefaults. AI-Generated-By: Antigravity AI-Model: Gemini 3.5 Flash AI-Agent: N/A AI-Skill: N/A --- Resources/en.lproj/Localizable.strings | 6 +- Resources/zh-Hans.lproj/Localizable.strings | 6 +- Sources/Signal/AppDelegate.swift | 19 ++- Sources/Signal/Models/FlashMode.swift | 21 ++++ .../Signal/Models/ScreenOverlayManager.swift | 95 +++++++++++++++ Sources/Signal/ViewModels/AppViewModel.swift | 19 +++ .../ViewModels/ScreenOverlayViewModel.swift | 108 ++++++++++++++++++ Sources/Signal/Views/StatusControlView.swift | 23 ++++ Sources/Signal/Views/VignetteView.swift | 42 +++++++ 9 files changed, 332 insertions(+), 7 deletions(-) create mode 100644 Sources/Signal/Models/FlashMode.swift create mode 100644 Sources/Signal/Models/ScreenOverlayManager.swift create mode 100644 Sources/Signal/ViewModels/ScreenOverlayViewModel.swift create mode 100644 Sources/Signal/Views/VignetteView.swift diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 9d941dd..18e71f9 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -27,4 +27,8 @@ "Could not locate the 'sgnl' binary inside the application bundle resources." = "Could not locate the 'sgnl' binary inside the application bundle resources."; "An error occurred during installation:\n%@" = "An error occurred during installation:\n%@"; "OK" = "OK"; - +"Screen Flash Effect" = "Screen Flash Effect"; +"Gaming-style overlay on color change" = "Gaming-style overlay on color change"; +"1 Flash" = "1 Flash"; +"3 Flashes" = "3 Flashes"; +"Continuous" = "Continuous"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index e22db70..0122178 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -27,4 +27,8 @@ "Could not locate the 'sgnl' binary inside the application bundle resources." = "无法在应用包资源中找到 'sgnl' 二进制文件。"; "An error occurred during installation:\n%@" = "安装过程中发生错误:\n%@"; "OK" = "确定"; - +"Screen Flash Effect" = "屏幕闪烁效果"; +"Gaming-style overlay on color change" = "颜色改变时显示游戏风格的屏幕边缘覆层"; +"1 Flash" = "闪烁 1 次"; +"3 Flashes" = "闪烁 3 次"; +"Continuous" = "持续呼吸"; diff --git a/Sources/Signal/AppDelegate.swift b/Sources/Signal/AppDelegate.swift index 99107f2..c15e978 100644 --- a/Sources/Signal/AppDelegate.swift +++ b/Sources/Signal/AppDelegate.swift @@ -30,6 +30,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { applyIcon() setupPopover() + // Start screen overlay manager + ScreenOverlayManager.shared.start() + // Listen for color-change commands from sgnl DistributedNotificationCenter.default().addObserver( self, @@ -192,11 +195,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: Color Switching - private func switchColor(to newColor: LightColor) { - guard newColor != currentColor else { return } - currentColor = newColor - viewModel.currentColor = newColor - applyIcon() + private func switchColor(to newColor: LightColor, forceFlash: Bool = true) { + let changed = (newColor != currentColor) + if changed { + currentColor = newColor + viewModel.currentColor = newColor + applyIcon() + } + + if changed || forceFlash { + ScreenOverlayManager.shared.triggerFlash(color: newColor, mode: viewModel.screenFlashMode) + } } @objc private func toggleBreathing() { diff --git a/Sources/Signal/Models/FlashMode.swift b/Sources/Signal/Models/FlashMode.swift new file mode 100644 index 0000000..aeff4c9 --- /dev/null +++ b/Sources/Signal/Models/FlashMode.swift @@ -0,0 +1,21 @@ +import Foundation + +enum FlashMode: String, CaseIterable { + case off + case alertOnce + case alertThree + case continuous + + var label: String { + switch self { + case .off: + return NSLocalizedString("Off", comment: "") + case .alertOnce: + return NSLocalizedString("1 Flash", comment: "") + case .alertThree: + return NSLocalizedString("3 Flashes", comment: "") + case .continuous: + return NSLocalizedString("Continuous", comment: "") + } + } +} diff --git a/Sources/Signal/Models/ScreenOverlayManager.swift b/Sources/Signal/Models/ScreenOverlayManager.swift new file mode 100644 index 0000000..286ce76 --- /dev/null +++ b/Sources/Signal/Models/ScreenOverlayManager.swift @@ -0,0 +1,95 @@ +import Cocoa +import SwiftUI + +class OverlayWindow: NSPanel { + init(screen: NSScreen, viewModel: ScreenOverlayViewModel) { + super.init( + contentRect: screen.frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + self.title = "Signal Screen Overlay" + self.isOpaque = false + self.backgroundColor = .clear + self.hasShadow = false + self.ignoresMouseEvents = true + self.level = .statusBar + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + let hostingView = NSHostingView(rootView: OverlayContentView(viewModel: viewModel)) + hostingView.frame = NSRect(origin: .zero, size: screen.frame.size) + self.contentView = hostingView + } + + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} + +class ScreenOverlayManager { + static let shared = ScreenOverlayManager() + + let viewModel = ScreenOverlayViewModel() + private var windows: [OverlayWindow] = [] + + init() { + viewModel.onAnimationStart = { [weak self] in + self?.showWindows() + } + viewModel.onAnimationEnd = { [weak self] in + self?.hideWindows() + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(screensDidChange), + name: NSApplication.didChangeScreenParametersNotification, + object: nil + ) + } + + func start() { + rebuildWindows() + } + + @objc private func screensDidChange() { + DispatchQueue.main.async { [weak self] in + self?.rebuildWindows() + } + } + + private func rebuildWindows() { + // Close existing windows + for window in windows { + window.close() + } + windows.removeAll() + + // Create new window for each screen + for screen in NSScreen.screens { + let window = OverlayWindow(screen: screen, viewModel: viewModel) + // If currently active/showing, order it front + if viewModel.opacity > 0 { + window.orderFrontRegardless() + } + windows.append(window) + } + } + + private func showWindows() { + for window in windows { + window.orderFrontRegardless() + } + } + + private func hideWindows() { + for window in windows { + window.orderOut(nil) + } + } + + func triggerFlash(color: LightColor, mode: FlashMode) { + viewModel.triggerFlash(color: color, mode: mode) + } +} diff --git a/Sources/Signal/ViewModels/AppViewModel.swift b/Sources/Signal/ViewModels/AppViewModel.swift index 8dfd102..c47d2e0 100644 --- a/Sources/Signal/ViewModels/AppViewModel.swift +++ b/Sources/Signal/ViewModels/AppViewModel.swift @@ -8,8 +8,27 @@ class AppViewModel: ObservableObject { @Published var selectedTool: ToolType = .claude @Published var historySessions: [ToolType: [HistorySession]] = [:] @Published var isLoadingSessions: Bool = false + @Published var screenFlashMode: FlashMode = .alertThree { + didSet { + UserDefaults.standard.set(screenFlashMode.rawValue, forKey: "screenFlashMode") + if screenFlashMode != .off { + ScreenOverlayManager.shared.triggerFlash(color: currentColor, mode: screenFlashMode) + } else { + ScreenOverlayManager.shared.triggerFlash(color: .black, mode: .off) + } + } + } private var hasLoadedSessions = false + init() { + if let savedValue = UserDefaults.standard.string(forKey: "screenFlashMode"), + let mode = FlashMode(rawValue: savedValue) { + self.screenFlashMode = mode + } else { + self.screenFlashMode = .alertThree + } + } + var claudeSessions: [HistorySession] { return historySessions[selectedTool] ?? [] } diff --git a/Sources/Signal/ViewModels/ScreenOverlayViewModel.swift b/Sources/Signal/ViewModels/ScreenOverlayViewModel.swift new file mode 100644 index 0000000..3ebbb06 --- /dev/null +++ b/Sources/Signal/ViewModels/ScreenOverlayViewModel.swift @@ -0,0 +1,108 @@ +import SwiftUI + +class ScreenOverlayViewModel: ObservableObject { + @Published var overlayColor: Color = .clear + @Published var opacity: Double = 0.0 + + var onAnimationStart: (() -> Void)? + var onAnimationEnd: (() -> Void)? + + private var currentAnimationId: UUID? + + func triggerFlash(color: LightColor, mode: FlashMode) { + let animationId = UUID() + self.currentAnimationId = animationId + + guard mode != .off, color != .black else { + // Cancel current overlay animation and fade out + withAnimation(.easeOut(duration: 0.3)) { + self.opacity = 0.0 + } + // After fade out, notify that animation ended to hide windows + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + guard let self = self, animationId == self.currentAnimationId else { return } + self.onAnimationEnd?() + } + return + } + + let swiftUIColor = Color(color.color) + self.overlayColor = swiftUIColor + self.opacity = 0.0 + + // Notify animation started (which will order Front the windows) + onAnimationStart?() + + runAnimation(id: animationId, mode: mode, period: color.period) + } + + private func runAnimation(id: UUID, mode: FlashMode, period: Double) { + guard id == currentAnimationId else { return } + + switch mode { + case .alertOnce: + withAnimation(.easeOut(duration: 0.3)) { + self.opacity = 1.0 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + guard let self = self, id == self.currentAnimationId else { return } + withAnimation(.easeIn(duration: 0.4)) { + self.opacity = 0.0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + guard let self = self, id == self.currentAnimationId else { return } + self.onAnimationEnd?() + } + } + + case .alertThree: + flashSequence(id: id, current: 1, total: 3) + + case .continuous: + // Continuous breathing/pulsing + pulseLoop(id: id, duration: period > 0 ? period / 2.0 : 1.2, targetHigh: true) + + case .off: + break + } + } + + private func flashSequence(id: UUID, current: Int, total: Int) { + guard id == currentAnimationId else { return } + withAnimation(.easeOut(duration: 0.25)) { + self.opacity = 1.0 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + guard let self = self, id == self.currentAnimationId else { return } + withAnimation(.easeIn(duration: 0.3)) { + self.opacity = 0.0 + } + + if current < total { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + guard let self = self, id == self.currentAnimationId else { return } + self.flashSequence(id: id, current: current + 1, total: total) + } + } else { + // Done with all flashes + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + guard let self = self, id == self.currentAnimationId else { return } + self.onAnimationEnd?() + } + } + } + } + + private func pulseLoop(id: UUID, duration: Double, targetHigh: Bool) { + guard id == currentAnimationId else { return } + withAnimation(.easeInOut(duration: duration)) { + self.opacity = targetHigh ? 0.75 : 0.15 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + guard let self = self, id == self.currentAnimationId else { return } + self.pulseLoop(id: id, duration: duration, targetHigh: !targetHigh) + } + } +} diff --git a/Sources/Signal/Views/StatusControlView.swift b/Sources/Signal/Views/StatusControlView.swift index 7440758..b62aaed 100644 --- a/Sources/Signal/Views/StatusControlView.swift +++ b/Sources/Signal/Views/StatusControlView.swift @@ -88,6 +88,29 @@ struct StatusControlView: View { .controlSize(.small) .labelsHidden() } + + // Screen Flash Effect Selector + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString("Screen Flash Effect", comment: "")) + .font(.system(size: 13, weight: .medium)) + Text(NSLocalizedString("Gaming-style overlay on color change", comment: "")) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + + Spacer() + + Picker("", selection: $viewModel.screenFlashMode) { + ForEach(FlashMode.allCases, id: \.self) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.menu) + .labelsHidden() + .controlSize(.small) + .frame(width: 120) + } } Divider() diff --git a/Sources/Signal/Views/VignetteView.swift b/Sources/Signal/Views/VignetteView.swift new file mode 100644 index 0000000..07285cc --- /dev/null +++ b/Sources/Signal/Views/VignetteView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct VignetteView: View { + let color: Color + + var body: some View { + GeometryReader { geo in + let maxDimension = max(geo.size.width, geo.size.height) + ZStack { + // Soft edge glow using radial gradient + RadialGradient( + gradient: Gradient(colors: [ + .clear, + color.opacity(0.12), + color.opacity(0.4) + ]), + center: .center, + startRadius: maxDimension * 0.35, + endRadius: maxDimension * 0.65 + ) + + // Intense border glow using thick stroked rectangle + Rectangle() + .stroke(color, lineWidth: 24) + .blur(radius: 20) + .padding(-12) // Keep the blur from getting cut off at the window bounds + .opacity(0.7) + } + } + .ignoresSafeArea() + } +} + +struct OverlayContentView: View { + @ObservedObject var viewModel: ScreenOverlayViewModel + + var body: some View { + VignetteView(color: viewModel.overlayColor) + .opacity(viewModel.opacity) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} From 9c3533b1d443073c4e950862f6feef903be18af2 Mon Sep 17 00:00:00 2001 From: Akey Zhang Date: Sun, 21 Jun 2026 11:11:40 +0800 Subject: [PATCH 8/8] fix(overlay): address code review findings and quality improvements Fix ScreenOverlayManager runtime crash, continuous mode window visibility, cross-layer coupling in AppViewModel, duplicate replies parsing, and timing constants. AI-Generated-By: Antigravity AI-Model: Gemini 3.5 Flash AI-Agent: N/A AI-Skill: N/A --- Sources/Signal/AppDelegate.swift | 20 ++- .../Signal/Models/ClaudeHistoryModel.swift | 147 +++++++++--------- Sources/Signal/Models/LightColor.swift | 24 +-- .../Signal/Models/ScreenOverlayManager.swift | 13 +- Sources/Signal/ViewModels/AppViewModel.swift | 7 +- .../ViewModels/ScreenOverlayViewModel.swift | 36 +++-- Sources/Signal/Views/StatusControlView.swift | 14 +- 7 files changed, 137 insertions(+), 124 deletions(-) diff --git a/Sources/Signal/AppDelegate.swift b/Sources/Signal/AppDelegate.swift index c15e978..b756d4f 100644 --- a/Sources/Signal/AppDelegate.swift +++ b/Sources/Signal/AppDelegate.swift @@ -88,6 +88,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { viewModel.onColorChange = { [weak self] color in self?.switchColor(to: color) } + viewModel.onFlashModeChange = { [weak self] mode in + guard let self = self else { return } + if mode != .off { + ScreenOverlayManager.shared.triggerFlash(color: self.currentColor, mode: mode) + } else { + ScreenOverlayManager.shared.triggerFlash(color: .black, mode: .off) + } + } viewModel.onBreathingChange = { [weak self] enabled in guard let self = self else { return } if self.breathingEnabled != enabled { @@ -167,7 +175,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { try fileManager.copyItem(at: sourceURL, to: targetURL) // Make executable (chmod +x) - chmod(targetURL.path, 0o755) + if chmod(targetURL.path, 0o755) != 0 { + print("Warning: failed to chmod target binary at \(targetURL.path)") + } // Show success alert let alert = NSAlert() @@ -195,7 +205,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: Color Switching - private func switchColor(to newColor: LightColor, forceFlash: Bool = true) { + private func switchColor(to newColor: LightColor, forceFlash: Bool = false) { let changed = (newColor != currentColor) if changed { currentColor = newColor @@ -208,13 +218,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - @objc private func toggleBreathing() { + private func toggleBreathing() { breathingEnabled.toggle() viewModel.breathingEnabled = breathingEnabled applyIcon() } - // MARK: Distributed Notification Handler + // MARK: - Distributed Notification Handler @objc private func didReceiveColorChange(_ notification: Notification) { guard let userInfo = notification.userInfo, @@ -223,7 +233,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { return } DispatchQueue.main.async { [weak self] in - self?.switchColor(to: color) + self?.switchColor(to: color, forceFlash: true) } } diff --git a/Sources/Signal/Models/ClaudeHistoryModel.swift b/Sources/Signal/Models/ClaudeHistoryModel.swift index df9c33e..801d8eb 100644 --- a/Sources/Signal/Models/ClaudeHistoryModel.swift +++ b/Sources/Signal/Models/ClaudeHistoryModel.swift @@ -1,7 +1,10 @@ import Foundation struct HistoryLine: Codable, Identifiable, Hashable { - var id: String { sessionId + "-\(timestamp)" } + var id: String { + let hash = display.utf8.reduce(5381) { ($0 << 5) &+ $0 &+ Int($1) } + return "\(sessionId)-\(timestamp)-\(hash)" + } let display: String let timestamp: Int64 let project: String @@ -78,7 +81,7 @@ struct SessionContentBlock: Codable { let thinking: String? } -class ClaudeHistoryLoader { +enum ClaudeHistoryLoader { static func loadPiHistory(completion: @escaping ([HistorySession]) -> Void) { DispatchQueue.global(qos: .userInitiated).async { let home = FileManager.default.homeDirectoryForCurrentUser @@ -128,10 +131,14 @@ class ClaudeHistoryLoader { continue } - let sessionId = sessionStart.id + let sessionId = sessionStart.id let projectPath = sessionStart.cwd let projectName = URL(fileURLWithPath: projectPath).lastPathComponent + let attrs = try? fileManager.attributesOfItem(atPath: file.path) + let creationDate = attrs?[.creationDate] as? Date ?? Date() + let fallbackTimestamp = Int64(creationDate.timeIntervalSince1970 * 1000) + var prompts: [HistoryLine] = [] for line in lines.dropFirst() { @@ -158,9 +165,7 @@ class ClaudeHistoryLoader { if let ts = msg.timestamp { timestamp = ts } else { - let attrs = try? fileManager.attributesOfItem(atPath: file.path) - let creationDate = attrs?[.creationDate] as? Date ?? Date() - timestamp = Int64(creationDate.timeIntervalSince1970 * 1000) + timestamp = fallbackTimestamp } let historyLine = HistoryLine( @@ -236,9 +241,7 @@ class ClaudeHistoryLoader { let timestamp: Int64? } - var currentPromptTimestamp: Int64? = nil - var accumulatedText: [String] = [] - var accumulatedThinking: [String] = [] + var events: [HistoryEvent] = [] for line in lines.dropFirst() { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) @@ -251,31 +254,18 @@ class ClaudeHistoryLoader { if parsedLine.type == "message", let msg = parsedLine.message { if msg.role == "user" { - if let prevTimestamp = currentPromptTimestamp { - let text = accumulatedText.joined(separator: "\n") - let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") - replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) - } - - accumulatedText = [] - accumulatedThinking = [] - - if let ts = msg.timestamp { - currentPromptTimestamp = ts - } else { - currentPromptTimestamp = nil - } + events.append(.user(timestamp: msg.timestamp)) } else if msg.role == "assistant" { if let content = msg.content { switch content { case .string(let str): - accumulatedText.append(str) + events.append(.assistantText(str)) case .array(let blocks): for block in blocks { if block.type == "text", let text = block.text { - accumulatedText.append(text) + events.append(.assistantText(text)) } else if block.type == "thinking", let thinking = block.thinking { - accumulatedThinking.append(thinking) + events.append(.assistantThinking(thinking)) } } } @@ -284,11 +274,7 @@ class ClaudeHistoryLoader { } } - if let prevTimestamp = currentPromptTimestamp { - let text = accumulatedText.joined(separator: "\n") - let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") - replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) - } + replies = accumulateReplies(from: events) } catch { // ignore } @@ -411,9 +397,7 @@ class ClaudeHistoryLoader { """ let rows = db.query(sql: querySql, parameters: [sessionId]) - var currentPromptTimestamp: Int64? = nil - var accumulatedText: [String] = [] - var accumulatedThinking: [String] = [] + var events: [HistoryEvent] = [] for row in rows { guard let role = row["role"], @@ -425,29 +409,17 @@ class ClaudeHistoryLoader { } if role == "user" { - if let prevTimestamp = currentPromptTimestamp { - let text = accumulatedText.joined(separator: "\n") - let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") - replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) - } - - accumulatedText = [] - accumulatedThinking = [] - currentPromptTimestamp = timeCreated + events.append(.user(timestamp: timeCreated)) } else if role == "assistant" { if partType == "text" { - accumulatedText.append(partText) + events.append(.assistantText(partText)) } else if partType == "reasoning" { - accumulatedThinking.append(partText) + events.append(.assistantThinking(partText)) } } } - if let prevTimestamp = currentPromptTimestamp { - let text = accumulatedText.joined(separator: "\n") - let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") - replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) - } + replies = accumulateReplies(from: events) DispatchQueue.main.async { completion(replies) @@ -562,6 +534,47 @@ class ClaudeHistoryLoader { } } + private enum HistoryEvent { + case user(timestamp: Int64?) + case assistantText(String) + case assistantThinking(String) + } + + private static func accumulateReplies(from events: [HistoryEvent]) -> [Int64: AssistantResponse] { + var replies: [Int64: AssistantResponse] = [:] + var currentPromptTimestamp: Int64? = nil + var accumulatedText: [String] = [] + var accumulatedThinking: [String] = [] + + for event in events { + switch event { + case .user(let timestamp): + if let prevTimestamp = currentPromptTimestamp { + let text = accumulatedText.joined(separator: "\n") + let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") + replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) + } + accumulatedText = [] + accumulatedThinking = [] + currentPromptTimestamp = timestamp + + case .assistantText(let text): + accumulatedText.append(text) + + case .assistantThinking(let thinking): + accumulatedThinking.append(thinking) + } + } + + if let prevTimestamp = currentPromptTimestamp { + let text = accumulatedText.joined(separator: "\n") + let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") + replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) + } + + return replies + } + static func loadReplies(projectPath: String, sessionId: String, completion: @escaping ([Int64: AssistantResponse]) -> Void) { DispatchQueue.global(qos: .userInitiated).async { let home = FileManager.default.homeDirectoryForCurrentUser @@ -586,10 +599,7 @@ class ClaudeHistoryLoader { let isoFormatterFallback = ISO8601DateFormatter() isoFormatterFallback.formatOptions = [.withInternetDateTime] - var currentPromptTimestamp: Int64? = nil - var accumulatedText: [String] = [] - var accumulatedThinking: [String] = [] - var replies: [Int64: AssistantResponse] = [:] + var events: [HistoryEvent] = [] for line in lines { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) @@ -601,36 +611,25 @@ class ClaudeHistoryLoader { } if parsedLine.type == "user" { - if let prevTimestamp = currentPromptTimestamp { - let text = accumulatedText.joined(separator: "\n") - let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") - replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) - } - - accumulatedText = [] - accumulatedThinking = [] - + var timestamp: Int64? = nil if let timestampStr = parsedLine.timestamp { let parsedDate = isoFormatter.date(from: timestampStr) ?? isoFormatterFallback.date(from: timestampStr) if let date = parsedDate { - currentPromptTimestamp = Int64(date.timeIntervalSince1970 * 1000) - } else { - currentPromptTimestamp = nil + timestamp = Int64(date.timeIntervalSince1970 * 1000) } - } else { - currentPromptTimestamp = nil } + events.append(.user(timestamp: timestamp)) } else if parsedLine.type == "assistant", let message = parsedLine.message { if let contentBlocks = message.content { switch contentBlocks { case .string(let str): - accumulatedText.append(str) + events.append(.assistantText(str)) case .array(let blocks): for block in blocks { if block.type == "text", let text = block.text { - accumulatedText.append(text) + events.append(.assistantText(text)) } else if block.type == "thinking", let thinking = block.thinking { - accumulatedThinking.append(thinking) + events.append(.assistantThinking(thinking)) } } } @@ -638,11 +637,7 @@ class ClaudeHistoryLoader { } } - if let prevTimestamp = currentPromptTimestamp { - let text = accumulatedText.joined(separator: "\n") - let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") - replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) - } + let replies = accumulateReplies(from: events) DispatchQueue.main.async { completion(replies) diff --git a/Sources/Signal/Models/LightColor.swift b/Sources/Signal/Models/LightColor.swift index aa4cf5f..74585fe 100644 --- a/Sources/Signal/Models/LightColor.swift +++ b/Sources/Signal/Models/LightColor.swift @@ -33,23 +33,25 @@ enum LightColor: String, CaseIterable { /// Whether the light breathes (animates). var breathes: Bool { self == .red || self == .yellow } - /// Display label for the menu item. - var label: String { - let text: String + /// Localized display name without emoji. + var displayName: String { switch self { - case .black: text = NSLocalizedString("Off", comment: "") - case .red: text = NSLocalizedString("Red", comment: "") - case .yellow: text = NSLocalizedString("Yellow", comment: "") - case .green: text = NSLocalizedString("Green", comment: "") + case .black: return NSLocalizedString("Off", comment: "") + case .red: return NSLocalizedString("Red", comment: "") + case .yellow: return NSLocalizedString("Yellow", comment: "") + case .green: return NSLocalizedString("Green", comment: "") } + } + /// Display label for the menu item. + var label: String { switch self { case .black: let isDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - return isDark ? "⚪ \(text)" : "⚫ \(text)" - case .red: return "🔴 \(text)" - case .yellow: return "🟡 \(text)" - case .green: return "🟢 \(text)" + return isDark ? "⚪ \(displayName)" : "⚫ \(displayName)" + case .red: return "🔴 \(displayName)" + case .yellow: return "🟡 \(displayName)" + case .green: return "🟢 \(displayName)" } } } diff --git a/Sources/Signal/Models/ScreenOverlayManager.swift b/Sources/Signal/Models/ScreenOverlayManager.swift index 286ce76..b07dd30 100644 --- a/Sources/Signal/Models/ScreenOverlayManager.swift +++ b/Sources/Signal/Models/ScreenOverlayManager.swift @@ -27,13 +27,16 @@ class OverlayWindow: NSPanel { override var canBecomeMain: Bool { false } } -class ScreenOverlayManager { +class ScreenOverlayManager: NSObject { static let shared = ScreenOverlayManager() let viewModel = ScreenOverlayViewModel() private var windows: [OverlayWindow] = [] + private var rebuildWorkItem: DispatchWorkItem? - init() { + override init() { + super.init() + viewModel.onAnimationStart = { [weak self] in self?.showWindows() } @@ -54,9 +57,13 @@ class ScreenOverlayManager { } @objc private func screensDidChange() { - DispatchQueue.main.async { [weak self] in + rebuildWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in self?.rebuildWindows() } + rebuildWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: workItem) } private func rebuildWindows() { diff --git a/Sources/Signal/ViewModels/AppViewModel.swift b/Sources/Signal/ViewModels/AppViewModel.swift index c47d2e0..e836c53 100644 --- a/Sources/Signal/ViewModels/AppViewModel.swift +++ b/Sources/Signal/ViewModels/AppViewModel.swift @@ -11,11 +11,7 @@ class AppViewModel: ObservableObject { @Published var screenFlashMode: FlashMode = .alertThree { didSet { UserDefaults.standard.set(screenFlashMode.rawValue, forKey: "screenFlashMode") - if screenFlashMode != .off { - ScreenOverlayManager.shared.triggerFlash(color: currentColor, mode: screenFlashMode) - } else { - ScreenOverlayManager.shared.triggerFlash(color: .black, mode: .off) - } + onFlashModeChange?(screenFlashMode) } } private var hasLoadedSessions = false @@ -77,6 +73,7 @@ class AppViewModel: ObservableObject { // Callback handlers to notify AppDelegate var onColorChange: ((LightColor) -> Void)? + var onFlashModeChange: ((FlashMode) -> Void)? var onBreathingChange: ((Bool) -> Void)? var onStartAtLoginChange: ((Bool) -> Void)? var onInstallCLI: (() -> Void)? diff --git a/Sources/Signal/ViewModels/ScreenOverlayViewModel.swift b/Sources/Signal/ViewModels/ScreenOverlayViewModel.swift index 3ebbb06..e4a2e6f 100644 --- a/Sources/Signal/ViewModels/ScreenOverlayViewModel.swift +++ b/Sources/Signal/ViewModels/ScreenOverlayViewModel.swift @@ -1,5 +1,17 @@ import SwiftUI +private enum Timing { + static let cancelFadeOut: Double = 0.3 + static let alertOnceFadeIn: Double = 0.3 + static let alertOnceHold: Double = 0.45 + static let alertOnceFadeOut: Double = 0.4 + static let alertThreeFadeIn: Double = 0.25 + static let alertThreeHold: Double = 0.3 + static let alertThreeFadeOut: Double = 0.3 + static let alertThreeGap: Double = 0.35 + static let fallbackPulse: Double = 1.2 +} + class ScreenOverlayViewModel: ObservableObject { @Published var overlayColor: Color = .clear @Published var opacity: Double = 0.0 @@ -15,11 +27,11 @@ class ScreenOverlayViewModel: ObservableObject { guard mode != .off, color != .black else { // Cancel current overlay animation and fade out - withAnimation(.easeOut(duration: 0.3)) { + withAnimation(.easeOut(duration: Timing.cancelFadeOut)) { self.opacity = 0.0 } // After fade out, notify that animation ended to hide windows - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + Timing.cancelFadeOut) { [weak self] in guard let self = self, animationId == self.currentAnimationId else { return } self.onAnimationEnd?() } @@ -41,16 +53,16 @@ class ScreenOverlayViewModel: ObservableObject { switch mode { case .alertOnce: - withAnimation(.easeOut(duration: 0.3)) { + withAnimation(.easeOut(duration: Timing.alertOnceFadeIn)) { self.opacity = 1.0 } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + Timing.alertOnceHold) { [weak self] in guard let self = self, id == self.currentAnimationId else { return } - withAnimation(.easeIn(duration: 0.4)) { + withAnimation(.easeIn(duration: Timing.alertOnceFadeOut)) { self.opacity = 0.0 } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + Timing.alertOnceFadeOut) { [weak self] in guard let self = self, id == self.currentAnimationId else { return } self.onAnimationEnd?() } @@ -61,7 +73,7 @@ class ScreenOverlayViewModel: ObservableObject { case .continuous: // Continuous breathing/pulsing - pulseLoop(id: id, duration: period > 0 ? period / 2.0 : 1.2, targetHigh: true) + pulseLoop(id: id, duration: period > 0 ? period / 2.0 : Timing.fallbackPulse, targetHigh: true) case .off: break @@ -70,23 +82,23 @@ class ScreenOverlayViewModel: ObservableObject { private func flashSequence(id: UUID, current: Int, total: Int) { guard id == currentAnimationId else { return } - withAnimation(.easeOut(duration: 0.25)) { + withAnimation(.easeOut(duration: Timing.alertThreeFadeIn)) { self.opacity = 1.0 } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + Timing.alertThreeHold) { [weak self] in guard let self = self, id == self.currentAnimationId else { return } - withAnimation(.easeIn(duration: 0.3)) { + withAnimation(.easeIn(duration: Timing.alertThreeFadeOut)) { self.opacity = 0.0 } if current < total { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + Timing.alertThreeGap) { [weak self] in guard let self = self, id == self.currentAnimationId else { return } self.flashSequence(id: id, current: current + 1, total: total) } } else { // Done with all flashes - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + Timing.alertThreeFadeOut) { [weak self] in guard let self = self, id == self.currentAnimationId else { return } self.onAnimationEnd?() } diff --git a/Sources/Signal/Views/StatusControlView.swift b/Sources/Signal/Views/StatusControlView.swift index b62aaed..4ac8442 100644 --- a/Sources/Signal/Views/StatusControlView.swift +++ b/Sources/Signal/Views/StatusControlView.swift @@ -150,7 +150,7 @@ struct StatusControlView: View { Spacer(minLength: 12) - // Section 3: Quit Button at bottom + // Quit Button at bottom Divider() Button(action: { @@ -196,7 +196,7 @@ struct ColorGridButton: View { ) .frame(width: 14, height: 14) - Text(cleanLabel(for: color)) + Text(color.displayName) .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) .foregroundColor(isSelected ? .primary : .secondary) @@ -221,16 +221,6 @@ struct ColorGridButton: View { isHovered = hovering } } - - private func cleanLabel(for color: LightColor) -> String { - // Strip out the leading emoji from the color label for cleaner grid display - let rawLabel = color.label - if rawLabel.contains("🔴") { return NSLocalizedString("Red", comment: "") } - if rawLabel.contains("🟡") { return NSLocalizedString("Yellow", comment: "") } - if rawLabel.contains("🟢") { return NSLocalizedString("Green", comment: "") } - if rawLabel.contains("⚪") || rawLabel.contains("⚫") { return NSLocalizedString("Off", comment: "") } - return rawLabel - } } // NSViewRepresentable wrapper to run breathing animations on the GPU (saving CPU)