mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
Merge remote-tracking branch 'upstream' into ENG-1874
This commit is contained in:
@@ -69,7 +69,10 @@ func (c *Client) buildArgs(config SessionConfig) ([]string, error) {
|
||||
args := []string{}
|
||||
|
||||
// Always use print mode for SDK
|
||||
args = append(args, "--print", config.Query)
|
||||
args = append(args, "--print")
|
||||
|
||||
// Title is stored in our database but not passed to Claude CLI
|
||||
// (Claude doesn't support --title flag)
|
||||
|
||||
// Session management
|
||||
if config.SessionID != "" {
|
||||
@@ -150,6 +153,11 @@ func (c *Client) buildArgs(config SessionConfig) ([]string, error) {
|
||||
args = append(args, "--verbose")
|
||||
}
|
||||
|
||||
// Query must be passed as a positional argument at the end
|
||||
if config.Query != "" {
|
||||
args = append(args, config.Query)
|
||||
}
|
||||
|
||||
return args, nil
|
||||
}
|
||||
|
||||
@@ -336,17 +344,18 @@ func (s *Session) parseStreamingJSON(stdout, stderr io.Reader) {
|
||||
// Store result if this is the final message
|
||||
if event.Type == "result" {
|
||||
s.result = &Result{
|
||||
Type: event.Type,
|
||||
Subtype: event.Subtype,
|
||||
CostUSD: event.CostUSD,
|
||||
IsError: event.IsError,
|
||||
DurationMS: event.DurationMS,
|
||||
DurationAPI: event.DurationAPI,
|
||||
NumTurns: event.NumTurns,
|
||||
Result: event.Result,
|
||||
SessionID: event.SessionID,
|
||||
Usage: event.Usage,
|
||||
Error: event.Error,
|
||||
Type: event.Type,
|
||||
Subtype: event.Subtype,
|
||||
CostUSD: event.CostUSD,
|
||||
IsError: event.IsError,
|
||||
DurationMS: event.DurationMS,
|
||||
DurationAPI: event.DurationAPI,
|
||||
NumTurns: event.NumTurns,
|
||||
Result: event.Result,
|
||||
SessionID: event.SessionID,
|
||||
Usage: event.Usage,
|
||||
Error: event.Error,
|
||||
PermissionDenials: event.PermissionDenials,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -492,3 +492,41 @@ func TestClaudeCodeSchemaCompatibility(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreamEventWithPermissionDenials(t *testing.T) {
|
||||
// Simulate Claude API response with permission denials
|
||||
jsonData := `{
|
||||
"type": "result",
|
||||
"subtype": "completion",
|
||||
"session_id": "test-session",
|
||||
"total_cost_usd": 0.05,
|
||||
"is_error": false,
|
||||
"result": "Successfully created PR #430",
|
||||
"permission_denials": [{
|
||||
"tool_name": "Bash",
|
||||
"tool_use_id": "toolu_01M6qJZgpwjmzg14TBS5Mwhm",
|
||||
"tool_input": {"command": "git fetch origin main"}
|
||||
}]
|
||||
}`
|
||||
|
||||
var event claudecode.StreamEvent
|
||||
err := json.Unmarshal([]byte(jsonData), &event)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal event with permission denials: %v", err)
|
||||
}
|
||||
|
||||
// Verify permission denials were parsed
|
||||
if event.PermissionDenials == nil || len(event.PermissionDenials.Denials) != 1 {
|
||||
t.Error("Permission denials not properly parsed")
|
||||
}
|
||||
|
||||
// Verify session is not marked as error despite denials
|
||||
if event.IsError {
|
||||
t.Error("Event marked as error when it should be successful with denials")
|
||||
}
|
||||
|
||||
// Verify denial details
|
||||
if event.PermissionDenials.Denials[0].ToolName != "Bash" {
|
||||
t.Errorf("Expected tool name 'Bash', got %s", event.PermissionDenials.Denials[0].ToolName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ type SessionConfig struct {
|
||||
// Required
|
||||
Query string
|
||||
|
||||
// Optional title for the session
|
||||
Title string
|
||||
|
||||
// Session management
|
||||
SessionID string // If set, resumes this session
|
||||
|
||||
@@ -87,15 +90,72 @@ type StreamEvent struct {
|
||||
APIKeySource string `json:"apiKeySource,omitempty"`
|
||||
|
||||
// Result event fields (when type="result")
|
||||
CostUSD float64 `json:"total_cost_usd,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
DurationMS int `json:"duration_ms,omitempty"`
|
||||
DurationAPI int `json:"duration_api_ms,omitempty"`
|
||||
NumTurns int `json:"num_turns,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
PermissionDenials []string `json:"permission_denials,omitempty"`
|
||||
CostUSD float64 `json:"total_cost_usd,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
DurationMS int `json:"duration_ms,omitempty"`
|
||||
DurationAPI int `json:"duration_api_ms,omitempty"`
|
||||
NumTurns int `json:"num_turns,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
PermissionDenials *PermissionDenials `json:"permission_denials,omitempty"`
|
||||
}
|
||||
|
||||
// PermissionDenial represents a single permission denial from Claude
|
||||
type PermissionDenial struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
ToolUseID string `json:"tool_use_id"`
|
||||
ToolInput map[string]interface{} `json:"tool_input,omitempty"`
|
||||
}
|
||||
|
||||
// PermissionDenials handles flexible permission denial formats
|
||||
type PermissionDenials struct {
|
||||
Denials []PermissionDenial
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom unmarshaling to handle both object and string array formats
|
||||
func (p *PermissionDenials) UnmarshalJSON(data []byte) error {
|
||||
// Handle null
|
||||
if string(data) == "null" {
|
||||
p.Denials = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try as array of objects first (current API format)
|
||||
var denials []PermissionDenial
|
||||
if err := json.Unmarshal(data, &denials); err == nil {
|
||||
p.Denials = denials
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fall back to array of strings (legacy/simple format)
|
||||
var legacyStrings []string
|
||||
if err := json.Unmarshal(data, &legacyStrings); err == nil {
|
||||
p.Denials = make([]PermissionDenial, len(legacyStrings))
|
||||
for i, s := range legacyStrings {
|
||||
p.Denials[i] = PermissionDenial{ToolName: s}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("permission_denials is neither object array nor string array format")
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom marshaling
|
||||
func (p PermissionDenials) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(p.Denials)
|
||||
}
|
||||
|
||||
// ToStrings converts denials to string array for backward compatibility
|
||||
func (p PermissionDenials) ToStrings() []string {
|
||||
if p.Denials == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, len(p.Denials))
|
||||
for i, d := range p.Denials {
|
||||
result[i] = d.ToolName
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MCPStatus represents the status of an MCP server
|
||||
@@ -182,18 +242,18 @@ type Usage struct {
|
||||
|
||||
// Result represents the final result of a Claude session
|
||||
type Result struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype"`
|
||||
CostUSD float64 `json:"total_cost_usd"`
|
||||
IsError bool `json:"is_error"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
DurationAPI int `json:"duration_api_ms"`
|
||||
NumTurns int `json:"num_turns"`
|
||||
Result string `json:"result"`
|
||||
SessionID string `json:"session_id"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
PermissionDenials []string `json:"permission_denials,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype"`
|
||||
CostUSD float64 `json:"total_cost_usd"`
|
||||
IsError bool `json:"is_error"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
DurationAPI int `json:"duration_api_ms"`
|
||||
NumTurns int `json:"num_turns"`
|
||||
Result string `json:"result"`
|
||||
SessionID string `json:"session_id"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
PermissionDenials *PermissionDenials `json:"permission_denials,omitempty"`
|
||||
}
|
||||
|
||||
// Session represents an active Claude session
|
||||
|
||||
@@ -207,3 +207,117 @@ func TestContentStructWithContentField(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionDenialsUnmarshal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectDenials []PermissionDenial
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "object array format",
|
||||
input: `[{
|
||||
"tool_name": "Bash",
|
||||
"tool_use_id": "toolu_01M6qJZgpwjmzg14TBS5Mwhm",
|
||||
"tool_input": {"command": "git fetch origin main"}
|
||||
}]`,
|
||||
expectDenials: []PermissionDenial{{
|
||||
ToolName: "Bash",
|
||||
ToolUseID: "toolu_01M6qJZgpwjmzg14TBS5Mwhm",
|
||||
ToolInput: map[string]interface{}{"command": "git fetch origin main"},
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "multiple denials",
|
||||
input: `[
|
||||
{"tool_name": "Bash", "tool_use_id": "tool_1", "tool_input": {"command": "rm -rf /"}},
|
||||
{"tool_name": "Write", "tool_use_id": "tool_2", "tool_input": {"path": "/etc/passwd"}}
|
||||
]`,
|
||||
expectDenials: []PermissionDenial{
|
||||
{ToolName: "Bash", ToolUseID: "tool_1", ToolInput: map[string]interface{}{"command": "rm -rf /"}},
|
||||
{ToolName: "Write", ToolUseID: "tool_2", ToolInput: map[string]interface{}{"path": "/etc/passwd"}},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "legacy string array format",
|
||||
input: `["Bash", "Write", "Delete"]`,
|
||||
expectDenials: []PermissionDenial{{ToolName: "Bash"}, {ToolName: "Write"}, {ToolName: "Delete"}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty array",
|
||||
input: `[]`,
|
||||
expectDenials: []PermissionDenial{},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "null value",
|
||||
input: `null`,
|
||||
expectDenials: nil,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid format - plain object",
|
||||
input: `{"tool_name": "Bash"}`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format - number",
|
||||
input: `123`,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var p PermissionDenials
|
||||
err := json.Unmarshal([]byte(tt.input), &p)
|
||||
|
||||
if (err != nil) != tt.expectError {
|
||||
t.Errorf("PermissionDenials.UnmarshalJSON() error = %v, expectError %v", err, tt.expectError)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.expectError {
|
||||
if len(p.Denials) != len(tt.expectDenials) {
|
||||
t.Errorf("PermissionDenials.UnmarshalJSON() got %d denials, want %d", len(p.Denials), len(tt.expectDenials))
|
||||
return
|
||||
}
|
||||
|
||||
for i, denial := range p.Denials {
|
||||
if denial.ToolName != tt.expectDenials[i].ToolName {
|
||||
t.Errorf("Denial[%d].ToolName = %v, want %v", i, denial.ToolName, tt.expectDenials[i].ToolName)
|
||||
}
|
||||
if denial.ToolUseID != tt.expectDenials[i].ToolUseID {
|
||||
t.Errorf("Denial[%d].ToolUseID = %v, want %v", i, denial.ToolUseID, tt.expectDenials[i].ToolUseID)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionDenialsToStrings(t *testing.T) {
|
||||
p := PermissionDenials{
|
||||
Denials: []PermissionDenial{
|
||||
{ToolName: "Bash"},
|
||||
{ToolName: "Write"},
|
||||
},
|
||||
}
|
||||
|
||||
strings := p.ToStrings()
|
||||
expected := []string{"Bash", "Write"}
|
||||
|
||||
if len(strings) != len(expected) {
|
||||
t.Errorf("ToStrings() returned %d items, want %d", len(strings), len(expected))
|
||||
}
|
||||
|
||||
for i, s := range strings {
|
||||
if s != expected[i] {
|
||||
t.Errorf("ToStrings()[%d] = %v, want %v", i, s, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ func (h *SessionHandlers) CreateSession(ctx context.Context, req api.CreateSessi
|
||||
}
|
||||
|
||||
// Handle optional fields
|
||||
if req.Body.Title != nil {
|
||||
config.Title = *req.Body.Title
|
||||
}
|
||||
if req.Body.PermissionPromptTool != nil {
|
||||
config.PermissionPromptTool = *req.Body.PermissionPromptTool
|
||||
}
|
||||
|
||||
@@ -696,6 +696,10 @@ components:
|
||||
type: string
|
||||
description: Initial query for Claude
|
||||
example: "Help me write a Python script to process CSV files"
|
||||
title:
|
||||
type: string
|
||||
description: Optional title for the session
|
||||
example: "CSV Processing Script"
|
||||
model:
|
||||
type: string
|
||||
enum: [opus, sonnet]
|
||||
|
||||
@@ -310,6 +310,9 @@ type CreateSessionRequest struct {
|
||||
// SystemPrompt Override system prompt
|
||||
SystemPrompt *string `json:"system_prompt,omitempty"`
|
||||
|
||||
// Title Optional title for the session
|
||||
Title *string `json:"title,omitempty"`
|
||||
|
||||
// Verbose Enable verbose output
|
||||
Verbose *bool `json:"verbose,omitempty"`
|
||||
|
||||
@@ -2182,96 +2185,94 @@ func (sh *strictHandler) GetSessionSnapshots(ctx *gin.Context, id SessionId) {
|
||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
"fwEAAP//OkGjnhNtAAA=",
|
||||
"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",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
||||
@@ -117,14 +117,14 @@ func TestIntegrationContinueSession(t *testing.T) {
|
||||
return nil, fmt.Errorf("no result in response")
|
||||
}
|
||||
|
||||
t.Run("ContinueSession_RequiresCompletedOrRunningParent", func(t *testing.T) {
|
||||
// Create a parent session that's failed (should be rejected)
|
||||
parentSessionID := "parent-failed"
|
||||
t.Run("ContinueSession_AllowsFailedSessionWithClaudeID", func(t *testing.T) {
|
||||
// Create a parent session that's failed with valid claude_session_id (should be allowed)
|
||||
parentSessionID := "parent-failed-valid"
|
||||
parentSession := &store.Session{
|
||||
ID: parentSessionID,
|
||||
RunID: "run-parent",
|
||||
ClaudeSessionID: "claude-parent",
|
||||
Status: store.SessionStatusFailed, // Neither completed nor running
|
||||
Status: store.SessionStatusFailed,
|
||||
Query: "original query",
|
||||
WorkingDir: "/tmp",
|
||||
CreatedAt: time.Now(),
|
||||
@@ -136,7 +136,44 @@ func TestIntegrationContinueSession(t *testing.T) {
|
||||
t.Fatalf("Failed to create parent session: %v", err)
|
||||
}
|
||||
|
||||
// Try to continue the failed session
|
||||
// Try to continue the failed session - should now succeed (or fail with Claude launch error)
|
||||
req := rpc.ContinueSessionRequest{
|
||||
SessionID: parentSessionID,
|
||||
Query: "continue this",
|
||||
}
|
||||
|
||||
_, err := sendRPC(t, "continueSession", req)
|
||||
if err != nil {
|
||||
// Expected - Claude binary might not exist in test environment
|
||||
expectedErr1 := "failed to continue session: failed to launch resumed Claude session: failed to start claude: exec: \"claude\": executable file not found in $PATH"
|
||||
expectedErr2 := "failed to continue session: failed to launch resumed Claude session: failed to start claude: chdir"
|
||||
if err.Error() != expectedErr1 && !strings.Contains(err.Error(), expectedErr2) {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
// Even if Claude fails to launch, the session should have been created
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ContinueSession_RejectsFailedSessionWithoutClaudeID", func(t *testing.T) {
|
||||
// Create a parent session that's failed WITHOUT claude_session_id (should still be rejected)
|
||||
parentSessionID := "parent-failed-no-claude"
|
||||
parentSession := &store.Session{
|
||||
ID: parentSessionID,
|
||||
RunID: "run-parent-no-claude",
|
||||
ClaudeSessionID: "", // Missing claude_session_id
|
||||
Status: store.SessionStatusFailed,
|
||||
Query: "original query",
|
||||
WorkingDir: "/tmp",
|
||||
CreatedAt: time.Now(),
|
||||
LastActivityAt: time.Now(),
|
||||
}
|
||||
|
||||
// Insert parent session directly into database
|
||||
if err := d.store.CreateSession(ctx, parentSession); err != nil {
|
||||
t.Fatalf("Failed to create parent session: %v", err)
|
||||
}
|
||||
|
||||
// Try to continue the failed session without claude_session_id
|
||||
req := rpc.ContinueSessionRequest{
|
||||
SessionID: parentSessionID,
|
||||
Query: "continue this",
|
||||
@@ -144,9 +181,43 @@ func TestIntegrationContinueSession(t *testing.T) {
|
||||
|
||||
_, err := sendRPC(t, "continueSession", req)
|
||||
if err == nil {
|
||||
t.Error("Expected error when continuing failed session")
|
||||
t.Error("Expected error when continuing failed session without claude_session_id")
|
||||
}
|
||||
if err.Error() != "cannot continue session with status failed (must be completed, interrupted, or running)" {
|
||||
if err.Error() != "parent session missing claude_session_id (cannot resume)" {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ContinueSession_RejectsInvalidStatus", func(t *testing.T) {
|
||||
// Create a parent session with an invalid status (e.g., starting)
|
||||
parentSessionID := "parent-invalid-status"
|
||||
parentSession := &store.Session{
|
||||
ID: parentSessionID,
|
||||
RunID: "run-invalid",
|
||||
ClaudeSessionID: "claude-invalid",
|
||||
Status: store.SessionStatusStarting, // Invalid status for continuation
|
||||
Query: "original query",
|
||||
WorkingDir: "/tmp",
|
||||
CreatedAt: time.Now(),
|
||||
LastActivityAt: time.Now(),
|
||||
}
|
||||
|
||||
// Insert parent session
|
||||
if err := d.store.CreateSession(ctx, parentSession); err != nil {
|
||||
t.Fatalf("Failed to create parent session: %v", err)
|
||||
}
|
||||
|
||||
// Try to continue with invalid status
|
||||
req := rpc.ContinueSessionRequest{
|
||||
SessionID: parentSessionID,
|
||||
Query: "continue this",
|
||||
}
|
||||
|
||||
_, err := sendRPC(t, "continueSession", req)
|
||||
if err == nil {
|
||||
t.Error("Expected error when continuing session with invalid status")
|
||||
}
|
||||
if err.Error() != "cannot continue session with status starting (must be completed, interrupted, running, or failed)" {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -231,14 +231,13 @@ func TestIntegrationResumeDuringRunning(t *testing.T) {
|
||||
{
|
||||
name: "failed session",
|
||||
status: store.SessionStatusFailed,
|
||||
shouldSucceed: false,
|
||||
expectedError: "cannot continue session with status failed (must be completed, interrupted, or running)",
|
||||
shouldSucceed: true, // Now allowed
|
||||
},
|
||||
{
|
||||
name: "starting session",
|
||||
status: store.SessionStatusStarting,
|
||||
shouldSucceed: false,
|
||||
expectedError: "cannot continue session with status starting (must be completed, interrupted, or running)",
|
||||
expectedError: "cannot continue session with status starting (must be completed, interrupted, running, or failed)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ func (h *SessionHandlers) SetEventBus(eventBus bus.EventBus) {
|
||||
// LaunchSessionRequest is the request for launching a new session
|
||||
type LaunchSessionRequest struct {
|
||||
Query string `json:"query"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
MCPConfig *claudecode.MCPConfig `json:"mcp_config,omitempty"`
|
||||
PermissionPromptTool string `json:"permission_prompt_tool,omitempty"`
|
||||
@@ -79,6 +80,7 @@ func (h *SessionHandlers) HandleLaunchSession(ctx context.Context, params json.R
|
||||
config := session.LaunchSessionConfig{
|
||||
SessionConfig: claudecode.SessionConfig{
|
||||
Query: req.Query,
|
||||
Title: req.Title,
|
||||
MCPConfig: req.MCPConfig,
|
||||
PermissionPromptTool: req.PermissionPromptTool,
|
||||
WorkingDir: req.WorkingDir,
|
||||
|
||||
@@ -33,6 +33,12 @@ export interface CreateSessionRequest {
|
||||
* @memberof CreateSessionRequest
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* Optional title for the session
|
||||
* @type {string}
|
||||
* @memberof CreateSessionRequest
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* Model to use for the session
|
||||
* @type {string}
|
||||
@@ -143,6 +149,7 @@ export function CreateSessionRequestFromJSONTyped(json: any, ignoreDiscriminator
|
||||
return {
|
||||
|
||||
'query': json['query'],
|
||||
'title': json['title'] == null ? undefined : json['title'],
|
||||
'model': json['model'] == null ? undefined : json['model'],
|
||||
'mcpConfig': json['mcp_config'] == null ? undefined : MCPConfigFromJSON(json['mcp_config']),
|
||||
'permissionPromptTool': json['permission_prompt_tool'] == null ? undefined : json['permission_prompt_tool'],
|
||||
@@ -171,6 +178,7 @@ export function CreateSessionRequestToJSONTyped(value?: CreateSessionRequest | n
|
||||
return {
|
||||
|
||||
'query': value['query'],
|
||||
'title': value['title'],
|
||||
'model': value['model'],
|
||||
'mcp_config': MCPConfigToJSON(value['mcpConfig']),
|
||||
'permission_prompt_tool': value['permissionPromptTool'],
|
||||
|
||||
@@ -1113,11 +1113,12 @@ func (m *Manager) ContinueSession(ctx context.Context, req ContinueSessionConfig
|
||||
return nil, fmt.Errorf("failed to get parent session: %w", err)
|
||||
}
|
||||
|
||||
// Validate parent session status - allow completed, interrupted, or running sessions
|
||||
// Validate parent session status - allow completed, interrupted, running, or failed sessions
|
||||
if parentSession.Status != store.SessionStatusCompleted &&
|
||||
parentSession.Status != store.SessionStatusInterrupted &&
|
||||
parentSession.Status != store.SessionStatusRunning {
|
||||
return nil, fmt.Errorf("cannot continue session with status %s (must be completed, interrupted, or running)", parentSession.Status)
|
||||
parentSession.Status != store.SessionStatusRunning &&
|
||||
parentSession.Status != store.SessionStatusFailed {
|
||||
return nil, fmt.Errorf("cannot continue session with status %s (must be completed, interrupted, running, or failed)", parentSession.Status)
|
||||
}
|
||||
|
||||
// Validate parent session has claude_session_id (needed for resume)
|
||||
@@ -1333,8 +1334,21 @@ func (m *Manager) ContinueSession(ctx context.Context, req ContinueSessionConfig
|
||||
}
|
||||
|
||||
// Launch resumed Claude session
|
||||
slog.Info("attempting to resume Claude session",
|
||||
"session_id", sessionID,
|
||||
"parent_session_id", req.ParentSessionID,
|
||||
"parent_status", parentSession.Status,
|
||||
"claude_session_id", parentSession.ClaudeSessionID,
|
||||
"query", req.Query)
|
||||
|
||||
claudeSession, err := m.client.Launch(config)
|
||||
if err != nil {
|
||||
slog.Error("failed to resume Claude session from failed parent",
|
||||
"session_id", sessionID,
|
||||
"parent_session_id", req.ParentSessionID,
|
||||
"parent_status", parentSession.Status,
|
||||
"claude_session_id", parentSession.ClaudeSessionID,
|
||||
"error", err)
|
||||
m.updateSessionStatus(ctx, sessionID, StatusFailed, err.Error())
|
||||
return nil, fmt.Errorf("failed to launch resumed Claude session: %w", err)
|
||||
}
|
||||
|
||||
@@ -151,22 +151,26 @@ func TestContinueSession_ValidatesParentStatus(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
parentStatus string
|
||||
hasWorkingDir bool
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "failed session",
|
||||
name: "failed session without working_dir",
|
||||
parentStatus: store.SessionStatusFailed,
|
||||
expectedError: "cannot continue session with status failed (must be completed, interrupted, or running)",
|
||||
hasWorkingDir: false,
|
||||
expectedError: "parent session missing working_dir (cannot resume session without working directory)",
|
||||
},
|
||||
{
|
||||
name: "starting session",
|
||||
parentStatus: store.SessionStatusStarting,
|
||||
expectedError: "cannot continue session with status starting (must be completed, interrupted, or running)",
|
||||
hasWorkingDir: true,
|
||||
expectedError: "cannot continue session with status starting (must be completed, interrupted, running, or failed)",
|
||||
},
|
||||
{
|
||||
name: "waiting input session",
|
||||
parentStatus: store.SessionStatusWaitingInput,
|
||||
expectedError: "cannot continue session with status waiting_input (must be completed, interrupted, or running)",
|
||||
hasWorkingDir: true,
|
||||
expectedError: "cannot continue session with status waiting_input (must be completed, interrupted, running, or failed)",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -180,6 +184,9 @@ func TestContinueSession_ValidatesParentStatus(t *testing.T) {
|
||||
Query: "original query",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if tc.hasWorkingDir {
|
||||
parentSession.WorkingDir = "/tmp"
|
||||
}
|
||||
mockStore.EXPECT().GetSession(gomock.Any(), "parent-1").Return(parentSession, nil)
|
||||
|
||||
req := ContinueSessionConfig{
|
||||
@@ -197,6 +204,64 @@ func TestContinueSession_ValidatesParentStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinueSession_AllowsFailedSessionWithValidRequirements(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
// Create a context that gets cancelled when test finishes
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() {
|
||||
cancel()
|
||||
// Give goroutines a moment to clean up
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}()
|
||||
|
||||
mockStore := store.NewMockConversationStore(ctrl)
|
||||
manager, _ := NewManager(nil, mockStore, "")
|
||||
|
||||
// Create a failed parent session WITH valid claude_session_id and working_dir
|
||||
failedParentSession := &store.Session{
|
||||
ID: "parent-failed-valid",
|
||||
RunID: "run-failed",
|
||||
ClaudeSessionID: "claude-failed-valid", // Has valid session ID
|
||||
Status: store.SessionStatusFailed,
|
||||
Query: "original query that failed",
|
||||
WorkingDir: "/tmp", // Has valid working directory
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
mockStore.EXPECT().GetSession(gomock.Any(), "parent-failed-valid").Return(failedParentSession, nil)
|
||||
mockStore.EXPECT().GetMCPServers(gomock.Any(), "parent-failed-valid").Return([]store.MCPServer{}, nil)
|
||||
mockStore.EXPECT().CreateSession(gomock.Any(), gomock.Any()).DoAndReturn(
|
||||
func(ctx interface{}, session *store.Session) error {
|
||||
// Validate the created session
|
||||
if session.ParentSessionID != "parent-failed-valid" {
|
||||
t.Errorf("Expected parent_session_id to be 'parent-failed-valid', got '%s'", session.ParentSessionID)
|
||||
}
|
||||
if session.Query != "retry after failure" {
|
||||
t.Errorf("Expected query 'retry after failure', got '%s'", session.Query)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
mockStore.EXPECT().StoreMCPServers(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
|
||||
mockStore.EXPECT().UpdateSession(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
|
||||
|
||||
req := ContinueSessionConfig{
|
||||
ParentSessionID: "parent-failed-valid",
|
||||
Query: "retry after failure",
|
||||
}
|
||||
|
||||
// This should now succeed (or fail with Claude launch error, not validation error)
|
||||
_, err := manager.ContinueSession(ctx, req)
|
||||
if err != nil {
|
||||
// Expected - Claude binary might not exist
|
||||
if !containsError(err, "failed to launch resumed Claude session") {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
// The important thing is we didn't get a validation error about the failed status
|
||||
}
|
||||
|
||||
func TestContinueSession_ValidatesClaudeSessionID(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
@@ -257,6 +257,7 @@ func NewSessionFromConfig(id, runID string, config claudecode.SessionConfig) *Se
|
||||
ID: id,
|
||||
RunID: runID,
|
||||
Query: config.Query,
|
||||
Title: config.Title,
|
||||
Model: string(config.Model),
|
||||
WorkingDir: config.WorkingDir,
|
||||
MaxTurns: config.MaxTurns,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from 'path'
|
||||
|
||||
interface LaunchOptions {
|
||||
query?: string
|
||||
title?: string
|
||||
model?: string
|
||||
workingDir?: string
|
||||
maxTurns?: number
|
||||
@@ -28,6 +29,7 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
|
||||
|
||||
console.log('Launching Claude Code session...')
|
||||
console.log('Query:', query)
|
||||
if (options.title) console.log('Title:', options.title)
|
||||
if (options.model) console.log('Model:', options.model)
|
||||
console.log('Working directory:', options.workingDir || process.cwd())
|
||||
console.log('Approvals enabled:', options.approvals !== false)
|
||||
@@ -62,6 +64,7 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
|
||||
// Launch the session
|
||||
const result = await client.launchSession({
|
||||
query: query,
|
||||
title: options.title,
|
||||
model: options.model,
|
||||
working_dir: options.workingDir || process.cwd(),
|
||||
max_turns: options.maxTurns,
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface Approval {
|
||||
|
||||
interface LaunchSessionRequest {
|
||||
query: string
|
||||
title?: string
|
||||
model?: string
|
||||
mcp_config?: unknown
|
||||
permission_prompt_tool?: string
|
||||
@@ -97,6 +98,8 @@ interface LaunchSessionRequest {
|
||||
disallowed_tools?: string[]
|
||||
custom_instructions?: string
|
||||
verbose?: boolean
|
||||
dangerously_skip_permissions?: boolean
|
||||
dangerously_skip_permissions_timeout?: number
|
||||
}
|
||||
|
||||
interface LaunchSessionResponse {
|
||||
|
||||
@@ -100,6 +100,7 @@ program
|
||||
.command('launch <query>')
|
||||
.description('Launch a new Claude Code session via the daemon')
|
||||
.option('-m, --model <model>', 'Model to use (opus or sonnet)', 'sonnet')
|
||||
.option('-t, --title <title>', 'Optional session title')
|
||||
.option('-w, --working-dir <path>', 'Working directory for the session')
|
||||
.option('--max-turns <number>', 'Maximum number of turns', parseInt)
|
||||
.option('--no-approvals', 'Disable HumanLayer approvals for high-stakes operations')
|
||||
|
||||
@@ -4,11 +4,12 @@ import { SearchInput } from './FuzzySearchInput'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
|
||||
import { useRecentPaths } from '@/hooks/useRecentPaths'
|
||||
import { Textarea } from './ui/textarea'
|
||||
import { Input } from './ui/input'
|
||||
import { Label } from './ui/label'
|
||||
import { hasContent, isEmptyOrWhitespace } from '@/utils/validation'
|
||||
|
||||
interface SessionConfig {
|
||||
query: string
|
||||
title?: string
|
||||
workingDir: string
|
||||
model?: string
|
||||
maxTurns?: number
|
||||
@@ -30,7 +31,7 @@ export default function CommandInput({
|
||||
onSubmit,
|
||||
placeholder = 'Enter your command...',
|
||||
isLoading = false,
|
||||
config = { query: '', workingDir: '' },
|
||||
config = { workingDir: '' },
|
||||
onConfigChange,
|
||||
}: CommandInputProps) {
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null)
|
||||
@@ -87,6 +88,19 @@ export default function CommandInput({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title Field (optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title (optional)</Label>
|
||||
<Input
|
||||
id="title"
|
||||
type="text"
|
||||
value={config.title || ''}
|
||||
onChange={e => onConfigChange?.({ ...config, title: e.target.value })}
|
||||
placeholder="Optional session title"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main query input */}
|
||||
<div className="relative space-y-2">
|
||||
<Label htmlFor="prompt">Prompt</Label>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const DangerousSkipPermissionsMonitor = () => {
|
||||
// Show notification using NotificationService
|
||||
await notificationService.notify({
|
||||
type: 'settings_changed',
|
||||
title: 'Dangerous skip permissions expired',
|
||||
title: 'Bypass permissions expired',
|
||||
body: `Session: ${session.title || session.summary || 'Untitled'}`,
|
||||
metadata: {
|
||||
sessionId: session.id,
|
||||
|
||||
@@ -79,7 +79,8 @@ export function Layout() {
|
||||
const session = sessionResponse.session
|
||||
|
||||
// Always update the session in the sessions list
|
||||
useStore.getState().updateSessionOptimistic(session_id, session)
|
||||
// Use updateSession (not updateSessionOptimistic) since this is data FROM the server
|
||||
useStore.getState().updateSession(session_id, session)
|
||||
|
||||
// Update active session detail if this is the currently viewed session
|
||||
const activeSessionId = useStore.getState().activeSessionDetail?.session?.id
|
||||
|
||||
@@ -40,7 +40,7 @@ export const SessionModeIndicator: FC<SessionModeIndicatorProps> = ({
|
||||
})
|
||||
|
||||
// Show notification
|
||||
toast.info('Dangerous skip permissions expired', {
|
||||
toast.info('Bypass permissions expired', {
|
||||
description: 'Manual approval required for all tools',
|
||||
duration: 5000,
|
||||
})
|
||||
|
||||
@@ -60,6 +60,26 @@ const DangerouslySkipPermissionsDialogContent: FC<{
|
||||
},
|
||||
)
|
||||
|
||||
// Add meta+enter to submit
|
||||
useHotkeys(
|
||||
'meta+enter, ctrl+enter',
|
||||
ev => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
|
||||
// Only submit if the button would be enabled
|
||||
if (!useTimeout || (timeoutMinutes !== '' && timeoutMinutes !== 0)) {
|
||||
handleConfirm()
|
||||
}
|
||||
},
|
||||
{
|
||||
enabled: isOpen,
|
||||
scopes: DangerouslySkipPermissionsHotkeysScope,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
)
|
||||
|
||||
// Reset to default when component mounts (dialog opens)
|
||||
React.useEffect(() => {
|
||||
setTimeoutMinutes(15)
|
||||
@@ -155,6 +175,11 @@ const DangerouslySkipPermissionsDialogContent: FC<{
|
||||
className="border-[var(--terminal-error)] text-[var(--terminal-error)] hover:bg-[var(--terminal-error)] hover:text-background disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Bypass Permissions
|
||||
{!useTimeout || (timeoutMinutes !== '' && timeoutMinutes !== 0) ? (
|
||||
<kbd className="ml-1 px-1 py-0.5 text-xs bg-muted/50 rounded">
|
||||
{navigator.platform.toLowerCase().includes('mac') ? '⌘' : 'Ctrl'}+⏎
|
||||
</kbd>
|
||||
) : null}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
||||
@@ -577,7 +577,12 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (dangerouslySkipPermissions) {
|
||||
// Get the current value from the store directly to avoid stale closure
|
||||
const currentSessionFromStore = useStore.getState().sessions.find(s => s.id === session.id)
|
||||
const currentDangerouslySkipPermissions =
|
||||
currentSessionFromStore?.dangerouslySkipPermissions ?? false
|
||||
|
||||
if (currentDangerouslySkipPermissions) {
|
||||
// Disable dangerous skip permissions
|
||||
try {
|
||||
await updateSessionOptimistic(session.id, {
|
||||
@@ -597,7 +602,7 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
preventDefault: true,
|
||||
scopes: SessionDetailHotkeysScope,
|
||||
},
|
||||
[session.id, dangerouslySkipPermissions],
|
||||
[session.id], // Remove dangerouslySkipPermissions from deps since we get it fresh each time
|
||||
)
|
||||
|
||||
// Handle dialog confirmation
|
||||
@@ -765,7 +770,7 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
useHotkeys(
|
||||
'enter',
|
||||
() => {
|
||||
if (responseInputRef.current && session.status !== SessionStatus.Failed) {
|
||||
if (responseInputRef.current) {
|
||||
responseInputRef.current.focus()
|
||||
}
|
||||
},
|
||||
@@ -935,7 +940,6 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
onSelectEvent={handleForkSelect}
|
||||
isOpen={forkViewOpen}
|
||||
onOpenChange={setForkViewOpen}
|
||||
sessionStatus={session.status}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1026,7 +1030,6 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
onSelectEvent={handleForkSelect}
|
||||
isOpen={forkViewOpen}
|
||||
onOpenChange={setForkViewOpen}
|
||||
sessionStatus={session.status}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1127,7 +1130,6 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
handleContinueSession={actions.handleContinueSession}
|
||||
handleResponseInputKeyDown={actions.handleResponseInputKeyDown}
|
||||
isForkMode={actions.isForkMode}
|
||||
onOpenForkView={() => setForkViewOpen(true)}
|
||||
/>
|
||||
{/* Session mode indicator - shows either dangerous skip permissions or auto-accept */}
|
||||
<SessionModeIndicator
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import React, { Fragment } from 'react'
|
||||
import { logger } from '@/lib/logging'
|
||||
|
||||
// --- Debug utilities ---
|
||||
// Preserved for debugging - uncomment the invocation below to enable
|
||||
// @ts-expect-error - Intentionally unused, preserved for debugging
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function debugLogSnapshotFailure(
|
||||
error: unknown,
|
||||
edits: { oldValue: string; newValue: string }[],
|
||||
fileContents?: string,
|
||||
) {
|
||||
logger.warn('Snapshot-based diff rendering failed, falling back to simple diff:', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
editsCount: edits.length,
|
||||
fileContentLength: fileContents?.length,
|
||||
firstEditPreview: edits[0]
|
||||
? {
|
||||
oldValue: edits[0].oldValue.substring(0, 50) + '...',
|
||||
newValue: edits[0].newValue.substring(0, 50) + '...',
|
||||
}
|
||||
: null,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Minimal diff utilities (no third-party libraries) ---
|
||||
function computeLineDiff(oldStr: string, newStr: string) {
|
||||
// Returns an array of { type: 'equal'|'add'|'remove'|'replace', oldLine?: string, newLine?: string, oldIndex?: number, newIndex?: number }
|
||||
@@ -600,19 +622,8 @@ export const CustomDiffViewer = ({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} catch (error) {
|
||||
// Log detailed context for debugging
|
||||
logger.warn('Snapshot-based diff rendering failed, falling back to simple diff:', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
editsCount: edits.length,
|
||||
fileContentLength: fileContents?.length,
|
||||
firstEditPreview: edits[0]
|
||||
? {
|
||||
oldValue: edits[0].oldValue.substring(0, 50) + '...',
|
||||
newValue: edits[0].newValue.substring(0, 50) + '...',
|
||||
}
|
||||
: null,
|
||||
})
|
||||
} catch {
|
||||
// debugLogSnapshotFailure(error, edits, fileContents) // uncomment to enable debug logging
|
||||
|
||||
// Recursively call self with undefined fileContents to trigger non-snapshot mode
|
||||
return <CustomDiffViewer fileContents={undefined} edits={edits} splitView={splitView} />
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConversationEvent, SessionStatus } from '@/lib/daemon/types'
|
||||
import { ConversationEvent } from '@/lib/daemon/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useStealHotkeyScope } from '@/hooks/useStealHotkeyScope'
|
||||
|
||||
@@ -22,7 +22,6 @@ interface ForkViewModalProps {
|
||||
onSelectEvent: (index: number | null) => void
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
sessionStatus?: SessionStatus
|
||||
}
|
||||
|
||||
function ForkViewModalContent({
|
||||
@@ -30,7 +29,6 @@ function ForkViewModalContent({
|
||||
selectedEventIndex,
|
||||
onSelectEvent,
|
||||
onClose,
|
||||
sessionStatus,
|
||||
}: Omit<ForkViewModalProps, 'isOpen' | 'onOpenChange'> & { onClose: () => void }) {
|
||||
// Steal hotkey scope when this component mounts
|
||||
useStealHotkeyScope(ForkViewModalHotkeysScope)
|
||||
@@ -57,8 +55,8 @@ function ForkViewModalContent({
|
||||
.filter(({ event }) => event.eventType === 'message' && event.role === 'user')
|
||||
.slice(1) // Exclude first message since it can't be forked
|
||||
|
||||
// Add current option as a special index (-1) only if session is not failed
|
||||
const showCurrentOption = sessionStatus !== SessionStatus.Failed
|
||||
// Add current option as a special index (-1) for all sessions
|
||||
const showCurrentOption = true
|
||||
const allOptions = showCurrentOption
|
||||
? [...userMessageIndices, { event: null, index: -1 }]
|
||||
: userMessageIndices
|
||||
@@ -193,7 +191,7 @@ function ForkViewModalContent({
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Current option - only show if session is not failed */}
|
||||
{/* Current option */}
|
||||
{showCurrentOption && (
|
||||
<div className="border-t mt-2 pt-2">
|
||||
<div
|
||||
@@ -239,7 +237,6 @@ export function ForkViewModal({
|
||||
onSelectEvent,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
sessionStatus,
|
||||
}: ForkViewModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
@@ -272,7 +269,6 @@ export function ForkViewModal({
|
||||
selectedEventIndex={selectedEventIndex}
|
||||
onSelectEvent={onSelectEvent}
|
||||
onClose={() => onOpenChange(false)}
|
||||
sessionStatus={sessionStatus}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,12 +3,10 @@ import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Session, SessionStatus } from '@/lib/daemon/types'
|
||||
import {
|
||||
getSessionStatusText,
|
||||
getInputPlaceholder,
|
||||
getHelpText,
|
||||
getForkInputPlaceholder,
|
||||
} from '@/components/internal/SessionDetail/utils/sessionStatus'
|
||||
import { GitBranch } from 'lucide-react'
|
||||
import { ResponseInputLocalStorageKey } from '@/components/internal/SessionDetail/hooks/useSessionActions'
|
||||
|
||||
interface ResponseInputProps {
|
||||
@@ -19,7 +17,6 @@ interface ResponseInputProps {
|
||||
handleContinueSession: () => void
|
||||
handleResponseInputKeyDown: (e: React.KeyboardEvent) => void
|
||||
isForkMode?: boolean
|
||||
onOpenForkView?: () => void
|
||||
}
|
||||
|
||||
export const ResponseInput = forwardRef<HTMLTextAreaElement, ResponseInputProps>(
|
||||
@@ -32,7 +29,6 @@ export const ResponseInput = forwardRef<HTMLTextAreaElement, ResponseInputProps>
|
||||
handleContinueSession,
|
||||
handleResponseInputKeyDown,
|
||||
isForkMode,
|
||||
onOpenForkView,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -64,22 +60,7 @@ export const ResponseInput = forwardRef<HTMLTextAreaElement, ResponseInputProps>
|
||||
// Regular help text
|
||||
return getHelpText(session.status)
|
||||
}
|
||||
// Only show the simple status text if session is failed AND not in fork mode
|
||||
if (session.status === SessionStatus.Failed && !isForkMode) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-muted-foreground">{getSessionStatusText(session.status)}</span>
|
||||
{onOpenForkView && (
|
||||
<Button variant="ghost" size="sm" onClick={onOpenForkView} className="h-8 gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Fork from previous
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise always show the input
|
||||
// Always show the input for all session states
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{isForkMode && <span className="text-sm font-medium">Fork from this message:</span>}
|
||||
|
||||
@@ -84,6 +84,23 @@ export function ToolResultModal({
|
||||
},
|
||||
)
|
||||
|
||||
// Handle 'i' to close (toggle behavior)
|
||||
useHotkeys(
|
||||
'i',
|
||||
ev => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
if (toolResult || toolCall) {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
{
|
||||
enabled: !!(toolResult || toolCall),
|
||||
scopes: ToolResultModalHotkeysScope,
|
||||
preventDefault: true,
|
||||
},
|
||||
)
|
||||
|
||||
const isOpen = !!(toolResult || toolCall)
|
||||
useStealHotkeyScope(ToolResultModalHotkeysScope, isOpen)
|
||||
|
||||
@@ -167,7 +184,7 @@ export function ToolResultModal({
|
||||
<kbd>j/k</kbd> or <kbd>↓/↑</kbd> to scroll
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<kbd>ESC</kbd> to close
|
||||
<kbd>i</kbd> or <kbd>ESC</kbd> to close
|
||||
</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -11,19 +11,19 @@ export const Kbd = ({
|
||||
export const getSessionStatusText = (status: string): string => {
|
||||
if (status === 'completed') return 'Continue this conversation with a new message'
|
||||
if (status === 'interrupted') return 'Session was interrupted - continue with a new message'
|
||||
if (status === 'failed') return 'Session failed - continue with a new message to retry'
|
||||
if (status === 'running' || status === 'starting')
|
||||
return 'Claude is working - you can interrupt with a new message'
|
||||
return 'Session must be completed to continue'
|
||||
}
|
||||
|
||||
export const getInputPlaceholder = (status: string): string => {
|
||||
if (status === 'failed') return 'Session failed - cannot continue...'
|
||||
if (status === 'failed') return 'Enter your message to retry from where it failed...'
|
||||
if (status === 'running' || status === 'starting') return 'Enter message to interrupt...'
|
||||
return 'Enter your message to continue the conversation...'
|
||||
}
|
||||
|
||||
export const getHelpText = (status: string): React.ReactNode => {
|
||||
if (status === 'failed') return 'Session failed - cannot continue'
|
||||
const isMac = navigator.platform.includes('Mac')
|
||||
const sendKey = isMac ? '⌘+Enter' : 'Ctrl+Enter'
|
||||
const skipKey = isMac ? '⌥+Y' : 'Alt+Y'
|
||||
|
||||
@@ -9,7 +9,7 @@ import { homeDir } from '@tauri-apps/api/path'
|
||||
import { logger } from '@/lib/logging'
|
||||
|
||||
interface SessionConfig {
|
||||
query: string
|
||||
title?: string
|
||||
workingDir: string
|
||||
model?: string
|
||||
maxTurns?: number
|
||||
@@ -41,6 +41,7 @@ interface LauncherState {
|
||||
}
|
||||
|
||||
const LAST_WORKING_DIR_KEY = 'humanlayer-last-working-dir'
|
||||
const SESSION_LAUNCHER_QUERY_KEY = 'session-launcher-query'
|
||||
|
||||
// Helper function to get default working directory
|
||||
const getDefaultWorkingDir = (): string => {
|
||||
@@ -48,12 +49,17 @@ const getDefaultWorkingDir = (): string => {
|
||||
return stored || '~/' // Default to home directory on first launch
|
||||
}
|
||||
|
||||
// Helper function to get saved query
|
||||
const getSavedQuery = (): string => {
|
||||
return localStorage.getItem(SESSION_LAUNCHER_QUERY_KEY) || ''
|
||||
}
|
||||
|
||||
export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
isOpen: false,
|
||||
mode: 'command',
|
||||
view: 'menu',
|
||||
query: '',
|
||||
config: { query: '', workingDir: getDefaultWorkingDir() },
|
||||
query: getSavedQuery(),
|
||||
config: { workingDir: getDefaultWorkingDir() },
|
||||
isLaunching: false,
|
||||
gPrefixMode: false,
|
||||
selectedMenuIndex: 0,
|
||||
@@ -68,23 +74,26 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
}),
|
||||
|
||||
close: () => {
|
||||
const savedQuery = getSavedQuery()
|
||||
set({
|
||||
isOpen: false,
|
||||
view: 'menu',
|
||||
query: '',
|
||||
config: { query: '', workingDir: getDefaultWorkingDir() },
|
||||
query: savedQuery,
|
||||
config: { workingDir: getDefaultWorkingDir() },
|
||||
selectedMenuIndex: 0,
|
||||
error: undefined,
|
||||
gPrefixMode: false,
|
||||
})
|
||||
},
|
||||
|
||||
setQuery: query =>
|
||||
set(state => ({
|
||||
setQuery: query => {
|
||||
// Save to localStorage on every change
|
||||
localStorage.setItem(SESSION_LAUNCHER_QUERY_KEY, query)
|
||||
return set({
|
||||
query,
|
||||
config: { ...state.config, query },
|
||||
error: undefined,
|
||||
})),
|
||||
})
|
||||
},
|
||||
|
||||
setConfig: config => set({ config, error: undefined }),
|
||||
|
||||
@@ -141,6 +150,7 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
|
||||
const request: LaunchSessionRequest = {
|
||||
query: query.trim(),
|
||||
title: config.title || undefined,
|
||||
working_dir: config.workingDir || undefined,
|
||||
model: config.model || undefined,
|
||||
max_turns: config.maxTurns || undefined,
|
||||
@@ -155,6 +165,9 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
localStorage.setItem(LAST_WORKING_DIR_KEY, config.workingDir)
|
||||
}
|
||||
|
||||
// Clear the saved query after successful launch
|
||||
localStorage.removeItem(SESSION_LAUNCHER_QUERY_KEY)
|
||||
|
||||
// Navigate to new session (will be handled by parent component)
|
||||
window.location.hash = `#/sessions/${response.sessionId}`
|
||||
|
||||
@@ -174,11 +187,12 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
},
|
||||
|
||||
createNewSession: () => {
|
||||
const savedQuery = getSavedQuery()
|
||||
// Switch to input mode for session creation
|
||||
set({
|
||||
view: 'input',
|
||||
query: '',
|
||||
config: { query: '', workingDir: getDefaultWorkingDir() },
|
||||
query: savedQuery,
|
||||
config: { workingDir: getDefaultWorkingDir() },
|
||||
error: undefined,
|
||||
})
|
||||
},
|
||||
@@ -189,18 +203,20 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
get().close()
|
||||
},
|
||||
|
||||
reset: () =>
|
||||
set({
|
||||
reset: () => {
|
||||
const savedQuery = getSavedQuery()
|
||||
return set({
|
||||
isOpen: false,
|
||||
mode: 'command',
|
||||
view: 'menu',
|
||||
query: '',
|
||||
config: { query: '', workingDir: getDefaultWorkingDir() },
|
||||
query: savedQuery,
|
||||
config: { workingDir: getDefaultWorkingDir() },
|
||||
selectedMenuIndex: 0,
|
||||
isLaunching: false,
|
||||
error: undefined,
|
||||
gPrefixMode: false,
|
||||
}),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
// Helper hook for global hotkey management
|
||||
|
||||
@@ -136,6 +136,7 @@ export class HTTPDaemonClient implements IDaemonClient {
|
||||
|
||||
const response = await this.client!.createSession({
|
||||
query: params.query,
|
||||
title: 'title' in params ? params.title : undefined,
|
||||
workingDir:
|
||||
'workingDir' in params ? params.workingDir : (params as LaunchSessionRequest).working_dir,
|
||||
model: model,
|
||||
|
||||
@@ -151,6 +151,7 @@ export enum ViewMode {
|
||||
// Legacy request/response types (for gradual migration)
|
||||
export interface LaunchSessionRequest {
|
||||
query: string
|
||||
title?: string
|
||||
model?: string
|
||||
mcp_config?: any
|
||||
permission_prompt_tool?: string
|
||||
|
||||
Reference in New Issue
Block a user