Files
superwork/tui/internal/tui/sessions11c.go
T
2026-06-23 05:02:15 +08:00

284 lines
8.7 KiB
Go

package tui
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"time"
tea "charm.land/bubbletea/v2"
"superwork-tui/internal/auth"
"superwork-tui/internal/cc"
"superwork-tui/internal/config"
"superwork-tui/internal/git"
"superwork-tui/internal/gitea"
"superwork-tui/internal/issue"
)
// watchBrainstormFn is injectable for tests; production code uses the real watcher.
var watchBrainstormFn = func(projectsDir string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
sid, err := cc.WatchForNewSession(ctx, cc.SessionWatchOpts{
ProjectsDir: projectsDir,
Timeout: 120 * time.Second,
})
return sid, err
}
type reviewResultMsg struct {
err error
}
type testResultMsg struct {
err error
}
type testSessionCapturedMsg struct {
issueNumber int
sessionID string
}
// reviewCmd opens/focuses the review session using codex resume.
func reviewCmd(iss issue.Issue, sm *SessionManager) tea.Cmd {
return func() tea.Msg {
if iss.ReviewSessionID == "" {
return reviewResultMsg{err: fmt.Errorf("issue #%d has no reviewSessionId", iss.Number)}
}
workspaceRoot, err := workspaceRootFn()
if err != nil {
return reviewResultMsg{err: fmt.Errorf("workspace root: %w", err)}
}
if iss.WorktreePath == "" {
return reviewResultMsg{err: fmt.Errorf("审查会话无法恢复 #%d: worktree 不存在", iss.Number)}
}
abs := filepath.Join(workspaceRoot, iss.WorktreePath)
if _, err := os.Stat(abs); err != nil {
return reviewResultMsg{err: fmt.Errorf("审查会话无法恢复 #%d: worktree 不存在", iss.Number)}
}
cmd := fmt.Sprintf("codex resume -c model_reasoning_effort=xhigh --dangerously-bypass-approvals-and-sandbox %s", shellQuote(iss.ReviewSessionID))
if err := sm.OpenOrFocus(context.Background(), PhaseReview, iss, abs, cmd); err != nil {
return reviewResultMsg{err: fmt.Errorf("open review session: %w", err)}
}
return reviewResultMsg{}
}
}
// resolveTestSessionCwd returns worktree abs path if it exists on disk, else workspaceRoot.
func resolveTestSessionCwd(workspaceRoot string, iss issue.Issue) string {
if iss.WorktreePath != "" {
abs := filepath.Join(workspaceRoot, iss.WorktreePath)
if _, err := os.Stat(abs); err == nil {
return abs
}
}
return workspaceRoot
}
// testCmd starts or resumes the test session.
func testCmd(iss issue.Issue, sm *SessionManager) tea.Cmd {
return func() tea.Msg {
workspaceRoot, err := workspaceRootFn()
if err != nil {
return testResultMsg{err: fmt.Errorf("workspace root: %w", err)}
}
cwd := resolveTestSessionCwd(workspaceRoot, iss)
settings, err := config.Load()
if err != nil {
settings = config.DefaultSettings()
}
profilePath := iss.TestProfilePath
if profilePath == "" {
profilePath = iss.ProfilePath
}
var command string
if iss.TestSessionID != "" {
command = claudeCmd(claudeCmdOpts{
ResumeSessionID: iss.TestSessionID,
ProfilePath: profilePath,
SystemPromptCommand: settings.SystemPromptCommand,
})
} else {
inWorktree := cwd != workspaceRoot
if !inWorktree && iss.PR == "" {
return testResultMsg{err: fmt.Errorf("issue #%d: no worktree and no PR for test session", iss.Number)}
}
var prompt string
if inWorktree {
prompt = "当前工单的改动代码就在这个 worktree 工作目录里,无需用 tea 拉取,直接开始:怎么在本地测试以确保本次改动符合预期?"
} else {
prompt = fmt.Sprintf("我已经合并了#%s号pr,你先通过tea熟悉本次pr改动的代码,然后,怎么在本地测试以确保改动符合预期?", iss.PR)
}
command = claudeCmd(claudeCmdOpts{
Prompt: prompt,
ProfilePath: profilePath,
SystemPromptCommand: settings.SystemPromptCommand,
})
}
if err := sm.OpenOrFocus(context.Background(), PhaseTest, iss, cwd, command); err != nil {
return testResultMsg{err: fmt.Errorf("open test session: %w", err)}
}
return testResultMsg{}
}
}
// watchTestSessionCmd watches for a new claude session in the test cwd's projects dir.
func watchTestSessionCmd(issueNumber int, cwd string) tea.Cmd {
return func() tea.Msg {
home, err := os.UserHomeDir()
if err != nil {
return testSessionCapturedMsg{issueNumber: issueNumber}
}
projectsDir := cc.ClaudeProjectsDir(home, cwd)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
sid, _ := cc.WatchForNewSession(ctx, cc.SessionWatchOpts{
ProjectsDir: projectsDir,
Timeout: 120 * time.Second,
})
return testSessionCapturedMsg{issueNumber: issueNumber, sessionID: sid}
}
}
// writeTestSessionCmd persists the captured testSessionId to Gitea state JSON.
func writeTestSessionCmd(issueNumber int, sessionID string) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
root, err := workspaceRootFn()
if err != nil {
return sessionActionResultMsg{}
}
remote, err := git.DetectRepo(root)
if err != nil {
return sessionActionResultMsg{}
}
token, err := auth.ResolveGiteaToken(remote.Host)
if err != nil {
return sessionActionResultMsg{}
}
client := gitea.New(remote.Host, token)
_ = issue.MergeStateJSON(ctx, client, remote.Owner, remote.Repo, issueNumber, map[string]any{
"testSessionId": sessionID,
})
return sessionActionResultMsg{}
}
}
// InjectFeedback tries impl window first, falls back to test window.
// Returns true if injection succeeded.
func InjectFeedback(sm *SessionManager, iss issue.Issue, text string) bool {
ctx := context.Background()
session := resolveSessionName()
implWin := windowName(iss.Number, PhaseImpl)
exists, err := sm.windowExists(session, implWin)
if err == nil && exists {
if injectErr := sm.Inject(ctx, PhaseImpl, iss, text); injectErr == nil {
return true
}
}
testWin := windowName(iss.Number, PhaseTest)
exists, err = sm.windowExists(session, testWin)
if err == nil && exists {
if injectErr := sm.Inject(ctx, PhaseTest, iss, text); injectErr == nil {
return true
}
}
return false
}
// brainstormStartCmd starts a new brainstorm session when no SessionID exists.
// brainstormStartedMsg is returned by brainstormStartCmd on successful window open.
// The model uses it to start the session-capture watcher.
type brainstormStartedMsg struct {
issueNumber int
workspaceRoot string
}
// brainstormSessionCapturedMsg carries the captured sessionId for a brainstorm session.
type brainstormSessionCapturedMsg struct {
issueNumber int
sessionID string
}
// brainstormStartCmd starts a fresh brainstorm session (no existing SessionID).
// On success it returns brainstormStartedMsg so the model can launch the watcher.
func brainstormStartCmd(iss issue.Issue, sm *SessionManager) tea.Cmd {
return func() tea.Msg {
workspaceRoot, err := workspaceRootFn()
if err != nil {
return sessionActionResultMsg{err: fmt.Errorf("workspace root: %w", err)}
}
settings, err := config.Load()
if err != nil {
settings = config.DefaultSettings()
}
prompt := cc.BrainstormContinuePrompt(settings, struct{ IssueNumber string }{
IssueNumber: strconv.Itoa(iss.Number),
})
command := claudeCmd(claudeCmdOpts{
Prompt: prompt,
ProfilePath: iss.ProfilePath,
SystemPromptCommand: settings.SystemPromptCommand,
})
if err := sm.OpenOrFocus(context.Background(), PhaseBrain, iss, workspaceRoot, command); err != nil {
return sessionActionResultMsg{err: fmt.Errorf("open brainstorm: %w", err)}
}
return brainstormStartedMsg{issueNumber: iss.Number, workspaceRoot: workspaceRoot}
}
}
// watchBrainstormSessionCmd watches for a new claude session in the workspace projects dir.
func watchBrainstormSessionCmd(issueNumber int, workspaceRoot string) tea.Cmd {
return func() tea.Msg {
home, err := os.UserHomeDir()
if err != nil {
return brainstormSessionCapturedMsg{issueNumber: issueNumber}
}
projectsDir := cc.ClaudeProjectsDir(home, workspaceRoot)
sid, _ := watchBrainstormFn(projectsDir)
return brainstormSessionCapturedMsg{issueNumber: issueNumber, sessionID: sid}
}
}
// writeBrainstormSessionCmd persists the captured sessionId to Gitea state JSON.
func writeBrainstormSessionCmd(issueNumber int, sessionID string) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
root, err := workspaceRootFn()
if err != nil {
return sessionActionResultMsg{}
}
remote, err := git.DetectRepo(root)
if err != nil {
return sessionActionResultMsg{}
}
token, err := auth.ResolveGiteaToken(remote.Host)
if err != nil {
return sessionActionResultMsg{}
}
client := gitea.New(remote.Host, token)
_ = issue.MergeStateJSON(ctx, client, remote.Owner, remote.Repo, issueNumber, map[string]any{
"sessionId": sessionID,
})
return sessionActionResultMsg{}
}
}